From e15d56e2ba652f096505d12b8cd7feeeddafc511 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:42:47 +0000 Subject: [PATCH 001/110] docs: outline non-turn-based surface problem --- docs/planning/ntsb.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/planning/ntsb.md diff --git a/docs/planning/ntsb.md b/docs/planning/ntsb.md new file mode 100644 index 000000000000..3fcbc9ef2423 --- /dev/null +++ b/docs/planning/ntsb.md @@ -0,0 +1,37 @@ +# Non-turn-based surfaces + +**Status:** exploratory planning + +## Overview + +T3 currently models interaction primarily as a conversation between one user and an agent. A user submits a message, the agent runs a turn, and T3 presents the resulting conversation and runtime state through clients that understand the full T3 model. + +Non-turn-based surfaces (NTBS) such as Discord, Jira, Teams, GitHub issues, and pull requests do not share those assumptions. They are independently owned collaboration systems where: + +- several people may interact with the same external object; +- messages, comments, and object state may be edited or deleted after T3 first observes them; +- objects may be closed, reopened, moved, locked, or otherwise changed outside T3; +- events may arrive late, more than once, or after T3 has been offline; +- the platform can render only a small part of the state and activity available in a native T3 client. + +The problem is to define how these surfaces participate in T3 without creating a second domain model beside T3's existing one. The T3 event log remains canonical. NTBS support should select and project an explicit subset of existing T3 commands, events, and state, while platform adapters translate between that subset and each platform's native concepts. + +This requires a shared contract that answers several questions consistently across platforms: + +- what an external object corresponds to in T3; +- which T3 commands an external participant may cause; +- which T3 events and projected state an NTBS client may observe; +- how later external changes, multiple participants, retries, and replay affect that state; +- what limited clients render, ignore, or report as unsupported. + +The shared contract should be smaller than the full interactive-client protocol, event-based, and usable through both snapshots and incremental changes. Platform adapters should remain responsible for authentication, source-event translation, transport, and rendering—not for defining their own conversation semantics. + +## Scope of this document + +This document will capture the protocol design one decision at a time. It does not yet prescribe an object-to-thread mapping, a command or event subset, lifecycle semantics, cursor rules, or adapter behavior. Those decisions will be added only after they are discussed and agreed. + +Implementation is out of scope for this planning stage. + +## Agreed decisions + +None yet. From 08cf077ca2e66feba59544ac447088e385f7ac7a Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:52:34 +0000 Subject: [PATCH 002/110] docs: develop NTBS event execution proposal --- docs/planning/ntsb.md | 45 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/planning/ntsb.md b/docs/planning/ntsb.md index 3fcbc9ef2423..66e0e7921318 100644 --- a/docs/planning/ntsb.md +++ b/docs/planning/ntsb.md @@ -32,6 +32,49 @@ This document will capture the protocol design one decision at a time. It does n Implementation is out of scope for this planning stage. +## Proposal: A triggering event creates a new thread + +Each accepted external event that triggers an agent turn creates a new T3 thread. The first user message is constructed from the current authorized snapshot of the external source together with the event that triggered the run. In the existing T3 protocol, this can be expressed with `thread.turn.start` and `bootstrap.createThread`; it does not require a separate NTBS command. + +An external event is not the same as a delivery attempt. Retries and duplicate webhook deliveries must resolve to the same accepted event and must not create additional threads. The identity and idempotency rules needed to guarantee this are still to be designed. + +Triggering events for the same external interaction are processed in order. If an earlier event's T3 thread is still running, a later event waits. It does not run concurrently and does not alter, steer, or continue the active thread. When its turn comes, the later event starts its own T3 thread. + +### Advantages + +- The external source remains the participant-visible context for the run; behavior does not depend on hidden T3 conversation history that external participants cannot inspect. +- Each run has an isolated and auditable input, actor, output, and lifecycle. +- An edit can trigger a new run from the updated source snapshot without rewriting the history of a previous T3 thread. +- Different participants do not implicitly inherit stale or private context accumulated in an earlier agent session. +- Replay can reconstruct what the agent was asked to do from the captured source version and triggering event. +- Closing, reopening, deleting, or moving an external object does not need to masquerade as T3 thread lifecycle. +- The normal path initially needs only the existing bootstrap form of `thread.turn.start`. + +### Costs and limitations + +- Rebuilding the external snapshot for every run may increase prompt size, latency, and model cost. +- T3-only context such as intermediate tool activity or prior instructions is lost unless it is deliberately included in the new prompt. +- A high-volume external discussion may create many short-lived T3 threads, increasing storage and making native T3 discovery noisier. +- Independent threads can still target the same worktree or other mutable resource, so execution needs separate serialization or conflict rules. +- An external reply cannot continue an in-flight approval, user-input request, interrupt, or steering interaction merely by creating another thread; those interactions would need an explicit command targeting the existing thread or be unsupported. +- Reliable replay requires the system to retain or reconstruct the exact authorized source snapshot used for the run, not merely fetch whatever the source contains later. + +## Open questions + +- Which external messages or state changes trigger an agent turn, and which are ignored or recorded without starting work? +- What identifies the same external interaction for sequential processing: a Jira issue, Discord thread, GitHub issue or pull request, nested review discussion, Teams conversation, or another scope? +- Is the source snapshot frozen when an event is accepted or fetched when its queued execution begins? +- If the source is edited or deleted while its event is waiting, does the queued event retain its original snapshot, get replaced, or get cancelled? +- What stable identity distinguishes an accepted external event from duplicate, retried, late, or out-of-order deliveries? +- Does sequential processing apply only within one external interaction, or must executions that share a worktree or another mutable resource also wait for each other? +- Is bootstrap `thread.turn.start` the only command NTBS may issue, or are any commands targeting an existing execution thread supported? +- Which T3 events and projected fields may NTBS clients consume, and which are deliberately omitted or unsupported? +- How do clients obtain an initial snapshot, resume from a cursor, replay missed changes, and recover when their cursor is no longer valid? +- How are completion, failure, timeout, and cancellation represented and rendered on limited external platforms? +- How is an external event correlated with its T3 execution thread and the response rendered back onto the source? +- How long are captured source snapshots, execution threads, and their correlation records retained, and how are they presented in native T3 clients? + ## Agreed decisions -None yet. +- Each accepted external event that triggers an agent turn creates a new T3 thread from the authorized external source snapshot and the triggering event. +- Triggering events for the same external interaction are processed sequentially. A later event waits for the earlier event's thread to finish, then starts a new thread. From 5e81c76ec06831dfb0a4b3a746342686fb0082b3 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 4 Aug 2026 16:01:23 +0200 Subject: [PATCH 003/110] chore: defined processing --- docs/planning/feedback.md | 60 ++++++++++++++++++++++++++ docs/planning/ntsb-event-processing.md | 60 ++++++++++++++++++++++++++ docs/planning/ntsb.md | 22 +++++----- 3 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 docs/planning/feedback.md create mode 100644 docs/planning/ntsb-event-processing.md diff --git a/docs/planning/feedback.md b/docs/planning/feedback.md new file mode 100644 index 000000000000..b465d3ffd3cb --- /dev/null +++ b/docs/planning/feedback.md @@ -0,0 +1,60 @@ +In ntsb-event-processing.md: + +- “authorized request” +- “enabled interaction” +- “accepted request/event” +- “qualifying event” +- “request for the agent” +- “new explicit invocation” +- “source snapshot permitted by the access check” +- “pending turn record” — especially wrong now that we decided not to + queue NTBS work + +- “stable source event identity” — this is appropriately a TODO, but + should be described consistently + +- “external interaction” +- “response destination” +- “correlation record” +- “shared-resource coordination” +- “provider execution” + +The most distracting ones are qualifying, authorized, accepted, and +enabled. I’d replace them with concrete language such as: + +- “an event that matches one of the triggers below” +- “an event accepted after webhook/authentication checks” +- “the external object or conversation that contains the event” +- “the exact comment or message to which T3 posts the answer” + +In ntsb.md: + +- “canonical” event log +- “explicit subset” of commands/events/state +- “projected state” +- “limited clients” +- “source-event translation” +- “accepted external event” +- “agent turn” +- “captured source snapshot” +- “response target” +- “correlation record” +- “T3-only context” +- “external interaction” +- “lifecycle semantics” +- “deliberately omitted or unsupported” + +There are also two concrete leftovers: + +- The open question at line 64 still says events may be “recorded + without starting work,” even though we moved that out of scope. + +- Line 70 is a decision—“NTBS does not target existing execution + threads”—but it is sitting among open questions and should not be + phrased as one. + +The biggest cleanup would be to remove qualifying, authorized, and +accepted wherever they are not carrying a distinct security or lifecycle +meaning, then define the few terms we actually need: external event, +external interaction, captured snapshot, T3 thread, and response +destination. diff --git a/docs/planning/ntsb-event-processing.md b/docs/planning/ntsb-event-processing.md new file mode 100644 index 000000000000..bb0254979c31 --- /dev/null +++ b/docs/planning/ntsb-event-processing.md @@ -0,0 +1,60 @@ +# NTSB event processing + +**Status:** exploratory planning + +This document defines how events arriving from non-turn-based surfaces are classified and converted into T3 work. It focuses on which events start new T3 threads, which events are ignored, and how independently started threads are correlated with their external events and responses. + +## Core rule + +An external event starts a new T3 thread when the adapter recognizes it as one of the trigger forms defined below. Each triggering event creates a new T3 thread. NTBS does not explicitly target, continue, steer, or modify an existing T3 thread. + +The event is captured together with the source snapshot used to construct the first user message. The adapter must distinguish a source event from delivery attempts. Retrying or redelivering the same source event must not create another T3 thread. + +## Platform triggers + +The following source interactions start a new thread: + +### Jira + +- A top-level comment mentioning the agent. +- A reply mentioning the agent. +- A comment edit that adds the agent mention to a comment that previously did not invoke the agent. +- An edit to a comment that already invoked the agent does not trigger a new thread merely because its content changed. A new turn requires a new explicit invocation under the edited comment. + +### GitHub + +- An issue or pull request comment mentioning the agent. +- A pull-request review comment or reply mentioning the agent. +- A comment edit that adds the agent mention to a comment that previously did not invoke the agent. +- An edit to a comment that already invoked the agent does not trigger a new thread merely because its content changed. A new turn requires a new explicit invocation under the edited comment. + +### Discord + +- A human message mentioning the configured agent user. +- A human reply to an agent-authored message. +- A message edit that adds the configured agent mention to a message that previously did not invoke the agent. +- Editing a message that already invoked the agent does not start another thread merely because its content changed; a new turn requires a new explicit invocation. + +## Processing a trigger + +When a source interaction matches one of the triggers above, the adapter deduplicates the source event and captures the source snapshot used for the new thread. TODO: define the source event identity and idempotency rules, including late and out-of-order deliveries. + +T3 then starts a new thread from that event and snapshot. A thread already running for the same external interaction does not delay, absorb, continue, or modify the new thread. + +## Events that do not trigger work + +- Edits to a comment that already invoked the agent, including edits that change its content, unless the edited comment contains a new explicit invocation. +- Duplicate or already-accepted deliveries do not create another thread. TODO: define the stable event identity and idempotency rules, including late and out-of-order deliveries. + +Events that do not match one of the triggers above are ignored. Whether adapters retain them for deduplication, audit, or external-state projection is a separate concern. + +## Concurrent turns + +Multiple events from the same external interaction may create T3 threads at the same time. Each thread produces an answer for its own triggering event and sends that answer to the exact response destination associated with that event. Threads may finish in any order; completion order does not change where their answers are sent. + +## Summary + +- An invocation creates an independent T3 thread; it does not target or continue an existing thread. +- Multiple events from the same external interaction may create concurrent threads. +- Each thread produces its own answer, routed to the exact response destination associated with its originating event. +- Duplicate or already-accepted deliveries do not create another thread. TODO: define stable event identity and idempotency rules. diff --git a/docs/planning/ntsb.md b/docs/planning/ntsb.md index 66e0e7921318..2e18792a4bc6 100644 --- a/docs/planning/ntsb.md +++ b/docs/planning/ntsb.md @@ -34,11 +34,11 @@ Implementation is out of scope for this planning stage. ## Proposal: A triggering event creates a new thread -Each accepted external event that triggers an agent turn creates a new T3 thread. The first user message is constructed from the current authorized snapshot of the external source together with the event that triggered the run. In the existing T3 protocol, this can be expressed with `thread.turn.start` and `bootstrap.createThread`; it does not require a separate NTBS command. +Each accepted external event that triggers an agent turn creates a new T3 thread. The first user message is constructed from the captured source snapshot of the external source together with the event that triggered the run. Authorization to access the source is checked separately. In the existing T3 protocol, this can be expressed with `thread.turn.start` and `bootstrap.createThread`; it does not require a separate NTBS command. An external event is not the same as a delivery attempt. Retries and duplicate webhook deliveries must resolve to the same accepted event and must not create additional threads. The identity and idempotency rules needed to guarantee this are still to be designed. -Triggering events for the same external interaction are processed in order. If an earlier event's T3 thread is still running, a later event waits. It does not run concurrently and does not alter, steer, or continue the active thread. When its turn comes, the later event starts its own T3 thread. +Triggering events for the same external interaction may start T3 threads concurrently. Each thread is correlated with the external event that created it, and its output is projected to that event's response target. A later event does not alter, steer, or continue an earlier thread merely because both belong to the same external interaction. ### Advantages @@ -57,17 +57,16 @@ Triggering events for the same external interaction are processed in order. If a - A high-volume external discussion may create many short-lived T3 threads, increasing storage and making native T3 discovery noisier. - Independent threads can still target the same worktree or other mutable resource, so execution needs separate serialization or conflict rules. - An external reply cannot continue an in-flight approval, user-input request, interrupt, or steering interaction merely by creating another thread; those interactions would need an explicit command targeting the existing thread or be unsupported. -- Reliable replay requires the system to retain or reconstruct the exact authorized source snapshot used for the run, not merely fetch whatever the source contains later. +- Reliable replay requires the system to retain or reconstruct the exact captured source snapshot used for the run, not merely fetch whatever the source contains later. ## Open questions -- Which external messages or state changes trigger an agent turn, and which are ignored or recorded without starting work? -- What identifies the same external interaction for sequential processing: a Jira issue, Discord thread, GitHub issue or pull request, nested review discussion, Teams conversation, or another scope? -- Is the source snapshot frozen when an event is accepted or fetched when its queued execution begins? -- If the source is edited or deleted while its event is waiting, does the queued event retain its original snapshot, get replaced, or get cancelled? +- What identifies the same external interaction for correlation and projection: a Jira issue, Discord thread, GitHub issue or pull request, nested review discussion, Teams conversation, or another scope? +- Is the source snapshot frozen when an event is accepted or fetched immediately before its thread starts? +- If resource coordination delays a thread after its event is accepted, does it retain its original snapshot, or may the snapshot be refreshed? - What stable identity distinguishes an accepted external event from duplicate, retried, late, or out-of-order deliveries? -- Does sequential processing apply only within one external interaction, or must executions that share a worktree or another mutable resource also wait for each other? -- Is bootstrap `thread.turn.start` the only command NTBS may issue, or are any commands targeting an existing execution thread supported? +- How are concurrent executions that share a worktree or another mutable resource coordinated without imposing an event queue? +- NTBS does not target existing execution threads; each accepted event uses the new-thread form of `thread.turn.start`. - Which T3 events and projected fields may NTBS clients consume, and which are deliberately omitted or unsupported? - How do clients obtain an initial snapshot, resume from a cursor, replay missed changes, and recover when their cursor is no longer valid? - How are completion, failure, timeout, and cancellation represented and rendered on limited external platforms? @@ -76,5 +75,6 @@ Triggering events for the same external interaction are processed in order. If a ## Agreed decisions -- Each accepted external event that triggers an agent turn creates a new T3 thread from the authorized external source snapshot and the triggering event. -- Triggering events for the same external interaction are processed sequentially. A later event waits for the earlier event's thread to finish, then starts a new thread. +### Which external messages or state changes trigger an agent turn, and which are ignored or recorded without starting work? + +The platform-specific trigger forms, ignored events, thread creation, and response routing are defined in [ntsb-event-processing.md](./ntsb-event-processing.md). From 66fc81677aac27a51a98f24b01e38557905f6bf8 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 4 Aug 2026 16:19:41 +0200 Subject: [PATCH 004/110] chore: planning of ntsb processing --- docs/planning/ntsb-event-processing.md | 7 +++++ docs/planning/ntsb.md | 41 ++++++-------------------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/docs/planning/ntsb-event-processing.md b/docs/planning/ntsb-event-processing.md index bb0254979c31..e8a7126bfec2 100644 --- a/docs/planning/ntsb-event-processing.md +++ b/docs/planning/ntsb-event-processing.md @@ -52,6 +52,13 @@ Events that do not match one of the triggers above are ignored. Whether adapters Multiple events from the same external interaction may create T3 threads at the same time. Each thread produces an answer for its own triggering event and sends that answer to the exact response destination associated with that event. Threads may finish in any order; completion order does not change where their answers are sent. +## Consequences + +- Each event has isolated T3 context; a thread does not inherit the conversation history of another event. +- Each response must retain the exact destination associated with its triggering event. +- Capturing the source snapshot supports reproducibility, but may increase input size, latency, and model cost. +- High-volume external interactions may create many T3 threads and increase storage and discovery noise. + ## Summary - An invocation creates an independent T3 thread; it does not target or continue an existing thread. diff --git a/docs/planning/ntsb.md b/docs/planning/ntsb.md index 2e18792a4bc6..734916fa9382 100644 --- a/docs/planning/ntsb.md +++ b/docs/planning/ntsb.md @@ -14,50 +14,27 @@ Non-turn-based surfaces (NTBS) such as Discord, Jira, Teams, GitHub issues, and - events may arrive late, more than once, or after T3 has been offline; - the platform can render only a small part of the state and activity available in a native T3 client. -The problem is to define how these surfaces participate in T3 without creating a second domain model beside T3's existing one. The T3 event log remains canonical. NTBS support should select and project an explicit subset of existing T3 commands, events, and state, while platform adapters translate between that subset and each platform's native concepts. +The problem is to define how these surfaces participate in T3 without creating a second domain model beside T3's existing one. The existing T3 event log remains the source of truth for T3 state. NTBS support should reuse existing T3 commands, events, and state where possible, while platform adapters translate between T3 and each platform's native concepts. This requires a shared contract that answers several questions consistently across platforms: -- what an external object corresponds to in T3; -- which T3 commands an external participant may cause; -- which T3 events and projected state an NTBS client may observe; -- how later external changes, multiple participants, retries, and replay affect that state; -- what limited clients render, ignore, or report as unsupported. +- how an external interaction is identified and related to its T3 threads; +- which T3 commands an adapter may issue in response to an external event; +- which T3 events and state an adapter may use to render a response on the external platform; +- how later edits, deletions, multiple participants, retries, and replay affect event handling and response rendering; +- what an adapter does when the external platform cannot represent a T3 event or response; -The shared contract should be smaller than the full interactive-client protocol, event-based, and usable through both snapshots and incremental changes. Platform adapters should remain responsible for authentication, source-event translation, transport, and rendering—not for defining their own conversation semantics. +The integration protocol should expose only the T3 commands and state needed by these adapters. Adapters should be able to obtain an initial state and then receive subsequent changes. Platform adapters should remain responsible for authentication, source-event translation, transport, and rendering—not for defining their own conversation semantics. ## Scope of this document -This document will capture the protocol design one decision at a time. It does not yet prescribe an object-to-thread mapping, a command or event subset, lifecycle semantics, cursor rules, or adapter behavior. Those decisions will be added only after they are discussed and agreed. +This document defines the protocol-level relationship between T3 and non-turn-based surfaces. It covers event processing and trigger rules, thread creation, interaction identity, lifecycle, client state, cursors, and adapter behavior. Detailed decisions may be developed in companion planning documents, but remain part of this document's scope. Implementation is out of scope for this planning stage. ## Proposal: A triggering event creates a new thread -Each accepted external event that triggers an agent turn creates a new T3 thread. The first user message is constructed from the captured source snapshot of the external source together with the event that triggered the run. Authorization to access the source is checked separately. In the existing T3 protocol, this can be expressed with `thread.turn.start` and `bootstrap.createThread`; it does not require a separate NTBS command. - -An external event is not the same as a delivery attempt. Retries and duplicate webhook deliveries must resolve to the same accepted event and must not create additional threads. The identity and idempotency rules needed to guarantee this are still to be designed. - -Triggering events for the same external interaction may start T3 threads concurrently. Each thread is correlated with the external event that created it, and its output is projected to that event's response target. A later event does not alter, steer, or continue an earlier thread merely because both belong to the same external interaction. - -### Advantages - -- The external source remains the participant-visible context for the run; behavior does not depend on hidden T3 conversation history that external participants cannot inspect. -- Each run has an isolated and auditable input, actor, output, and lifecycle. -- An edit can trigger a new run from the updated source snapshot without rewriting the history of a previous T3 thread. -- Different participants do not implicitly inherit stale or private context accumulated in an earlier agent session. -- Replay can reconstruct what the agent was asked to do from the captured source version and triggering event. -- Closing, reopening, deleting, or moving an external object does not need to masquerade as T3 thread lifecycle. -- The normal path initially needs only the existing bootstrap form of `thread.turn.start`. - -### Costs and limitations - -- Rebuilding the external snapshot for every run may increase prompt size, latency, and model cost. -- T3-only context such as intermediate tool activity or prior instructions is lost unless it is deliberately included in the new prompt. -- A high-volume external discussion may create many short-lived T3 threads, increasing storage and making native T3 discovery noisier. -- Independent threads can still target the same worktree or other mutable resource, so execution needs separate serialization or conflict rules. -- An external reply cannot continue an in-flight approval, user-input request, interrupt, or steering interaction merely by creating another thread; those interactions would need an explicit command targeting the existing thread or be unsupported. -- Reliable replay requires the system to retain or reconstruct the exact captured source snapshot used for the run, not merely fetch whatever the source contains later. +Each event that matches a trigger creates a new T3 thread from the event and its captured source snapshot; the detailed trigger, processing, concurrency, and response-routing rules are defined in [ntsb-event-processing.md](./ntsb-event-processing.md). ## Open questions From 2520ce9de29f573fbbdd2eadbf1827ffdccf53bb Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 4 Aug 2026 22:43:07 +0200 Subject: [PATCH 005/110] chore: update ntsb planning --- docs/planning/ntsb.md | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/planning/ntsb.md b/docs/planning/ntsb.md index 734916fa9382..0ecbb15f3814 100644 --- a/docs/planning/ntsb.md +++ b/docs/planning/ntsb.md @@ -36,22 +36,35 @@ Implementation is out of scope for this planning stage. Each event that matches a trigger creates a new T3 thread from the event and its captured source snapshot; the detailed trigger, processing, concurrency, and response-routing rules are defined in [ntsb-event-processing.md](./ntsb-event-processing.md). -## Open questions - -- What identifies the same external interaction for correlation and projection: a Jira issue, Discord thread, GitHub issue or pull request, nested review discussion, Teams conversation, or another scope? -- Is the source snapshot frozen when an event is accepted or fetched immediately before its thread starts? -- If resource coordination delays a thread after its event is accepted, does it retain its original snapshot, or may the snapshot be refreshed? -- What stable identity distinguishes an accepted external event from duplicate, retried, late, or out-of-order deliveries? -- How are concurrent executions that share a worktree or another mutable resource coordinated without imposing an event queue? -- NTBS does not target existing execution threads; each accepted event uses the new-thread form of `thread.turn.start`. -- Which T3 events and projected fields may NTBS clients consume, and which are deliberately omitted or unsupported? -- How do clients obtain an initial snapshot, resume from a cursor, replay missed changes, and recover when their cursor is no longer valid? -- How are completion, failure, timeout, and cancellation represented and rendered on limited external platforms? -- How is an external event correlated with its T3 execution thread and the response rendered back onto the source? -- How long are captured source snapshots, execution threads, and their correlation records retained, and how are they presented in native T3 clients? - ## Agreed decisions ### Which external messages or state changes trigger an agent turn, and which are ignored or recorded without starting work? The platform-specific trigger forms, ignored events, thread creation, and response routing are defined in [ntsb-event-processing.md](./ntsb-event-processing.md). + +### What identifies the same external interaction for correlation and projection? + +- Jira: the issue key or immutable issue ID. Comments and replies are events within that issue. +- Discord: the thread ID. The thread is the interaction. +- GitHub: the repository and pull-request number. Issue comments, review comments, and replies are events within that pull request; the triggering comment and any diff context belong to the individual event. +- Teams: unresolved. The likely scope is the conversation or reply-chain ID, with each message as its own event. + +### When does the adapter capture the source snapshot relative to receiving a trigger and creating the T3 thread? + +The adapter captures the source snapshot while processing the trigger, before creating the T3 thread. The new thread uses that captured snapshot. + +### How does T3 prevent repeated delivery of the same source event from creating multiple threads? + +Each adapter derives an idempotency key from the platform’s source-event identity and version. The adapter stores that key with the T3 thread created for the event. If the same key is delivered again, the adapter reuses the existing record and does not create another thread. A later edit or distinct source event receives a different key and may create a new thread. The exact event identity, versioning, and retention rules are platform-specific and remain to be defined. + +### How are concurrent NTBS threads isolated without an event queue? + +Each NTBS-triggered T3 thread receives its own worktree and branch before provider execution begins. Threads from the same external interaction can therefore run concurrently without sharing a mutable checkout or requiring an event queue. + +### How are completion, failure, timeout, and cancellation reported for an external event? + +They use the same response destination as the triggering event. Normal completion returns the agent’s answer; failure, timeout, or cancellation returns a response that explicitly reports the outcome and, where available, its reason. These outcomes do not create a separate external lifecycle or target a different thread. + +### How does T3 associate a thread's outcome with the external event that created it, and where does the adapter post that outcome? + +Each source event has a unique event ID. T3 stores a correlation record linking that event ID to the T3 thread, user message or turn, and exact response destination. When the turn ends, the adapter uses that record to post the answer or outcome back to the originating source. From fb35e27d58f3a847e731bfbf0e6a36ff0a7149d1 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 11:53:51 +0200 Subject: [PATCH 006/110] chore: planning ntsb output --- docs/planning/ntsb-event-processing.md | 33 ++++++++++++++++++-------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/planning/ntsb-event-processing.md b/docs/planning/ntsb-event-processing.md index e8a7126bfec2..ba3a3ef12448 100644 --- a/docs/planning/ntsb-event-processing.md +++ b/docs/planning/ntsb-event-processing.md @@ -4,64 +4,77 @@ This document defines how events arriving from non-turn-based surfaces are classified and converted into T3 work. It focuses on which events start new T3 threads, which events are ignored, and how independently started threads are correlated with their external events and responses. -## Core rule +## Inbound event processing + +### Core rule An external event starts a new T3 thread when the adapter recognizes it as one of the trigger forms defined below. Each triggering event creates a new T3 thread. NTBS does not explicitly target, continue, steer, or modify an existing T3 thread. The event is captured together with the source snapshot used to construct the first user message. The adapter must distinguish a source event from delivery attempts. Retrying or redelivering the same source event must not create another T3 thread. -## Platform triggers +### Platform triggers The following source interactions start a new thread: -### Jira +#### Jira - A top-level comment mentioning the agent. - A reply mentioning the agent. - A comment edit that adds the agent mention to a comment that previously did not invoke the agent. - An edit to a comment that already invoked the agent does not trigger a new thread merely because its content changed. A new turn requires a new explicit invocation under the edited comment. -### GitHub +#### GitHub - An issue or pull request comment mentioning the agent. - A pull-request review comment or reply mentioning the agent. - A comment edit that adds the agent mention to a comment that previously did not invoke the agent. - An edit to a comment that already invoked the agent does not trigger a new thread merely because its content changed. A new turn requires a new explicit invocation under the edited comment. -### Discord +#### Discord - A human message mentioning the configured agent user. - A human reply to an agent-authored message. - A message edit that adds the configured agent mention to a message that previously did not invoke the agent. - Editing a message that already invoked the agent does not start another thread merely because its content changed; a new turn requires a new explicit invocation. -## Processing a trigger +### Processing a trigger When a source interaction matches one of the triggers above, the adapter deduplicates the source event and captures the source snapshot used for the new thread. TODO: define the source event identity and idempotency rules, including late and out-of-order deliveries. T3 then starts a new thread from that event and snapshot. A thread already running for the same external interaction does not delay, absorb, continue, or modify the new thread. -## Events that do not trigger work +### Events that do not trigger work - Edits to a comment that already invoked the agent, including edits that change its content, unless the edited comment contains a new explicit invocation. - Duplicate or already-accepted deliveries do not create another thread. TODO: define the stable event identity and idempotency rules, including late and out-of-order deliveries. Events that do not match one of the triggers above are ignored. Whether adapters retain them for deduplication, audit, or external-state projection is a separate concern. -## Concurrent turns +### Concurrent turns Multiple events from the same external interaction may create T3 threads at the same time. Each thread produces an answer for its own triggering event and sends that answer to the exact response destination associated with that event. Threads may finish in any order; completion order does not change where their answers are sent. -## Consequences +### Consequences - Each event has isolated T3 context; a thread does not inherit the conversation history of another event. - Each response must retain the exact destination associated with its triggering event. - Capturing the source snapshot supports reproducibility, but may increase input size, latency, and model cost. - High-volume external interactions may create many T3 threads and increase storage and discovery noise. -## Summary +### Summary - An invocation creates an independent T3 thread; it does not target or continue an existing thread. - Multiple events from the same external interaction may create concurrent threads. - Each thread produces its own answer, routed to the exact response destination associated with its originating event. - Duplicate or already-accepted deliveries do not create another thread. TODO: define stable event identity and idempotency rules. + +## Outbound response processing + +The following questions remain open for the T3-to-NTBS path: + +- Which T3 outputs are rendered on the external platform: only the final answer, or also intermediate updates, tool results, attachments, and generated artifacts? +- Does the adapter post the result as a reply, create a new comment or message, or update a message created earlier for the same turn? +- How does each adapter translate T3 formatting and content into the external platform's supported format and size limits? +- What happens when a response cannot be posted, is posted only partially, or must be retried? +- How are responses to concurrent turns ordered or labeled when they appear in the same external interaction? +- Which T3 events are intentionally kept inside T3 rather than rendered externally? From b60d8bd469e79e88f6ac31c8a8bd790bc78f9998 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 13:53:33 +0200 Subject: [PATCH 007/110] chore: more processing --- docs/planning/ntsb-event-processing.md | 47 ++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/planning/ntsb-event-processing.md b/docs/planning/ntsb-event-processing.md index ba3a3ef12448..a4c269104b97 100644 --- a/docs/planning/ntsb-event-processing.md +++ b/docs/planning/ntsb-event-processing.md @@ -4,6 +4,12 @@ This document defines how events arriving from non-turn-based surfaces are classified and converted into T3 work. It focuses on which events start new T3 threads, which events are ignored, and how independently started threads are correlated with their external events and responses. +T3 clients are built around T3 data views (projections): threads, diffs, and projects. + +External NTBSs like Jira, Discord, or GitHub know nothing about that: they have only limited capabilities for sending and receiving messages. + +The UX on these platforms has to be thoroughly scoped, and adapters to these platforms have to be extended to retain the information needed to connect T3 events to Jira, Discord, GitHub, or Teams events. + ## Inbound event processing ### Core rule @@ -12,6 +18,15 @@ An external event starts a new T3 thread when the adapter recognizes it as one o The event is captured together with the source snapshot used to construct the first user message. The adapter must distinguish a source event from delivery attempts. Retrying or redelivering the same source event must not create another T3 thread. +### Adapter storage + +For each inbound event, the adapter retains: + +- the source event ID and version, to avoid handling the same event twice; +- the source context and message or comment IDs, so it knows where the event came from; +- the captured source snapshot; +- the T3 thread, user-message, and turn IDs created from the event. + ### Platform triggers The following source interactions start a new thread: @@ -70,10 +85,36 @@ Multiple events from the same external interaction may create T3 threads at the ## Outbound response processing -The following questions remain open for the T3-to-NTBS path: +Outbound processing adds the acknowledgement and final-outcome message IDs, together with whether each message was posted. + +### Agreed decisions + +Each inbound event creates an immediate outbound acknowledgement. When its T3 thread ends, the adapter sends the final answer, failure, timeout, or cancellation as a new message after that acknowledgement, in the platform's native conversation scope. + +#### Message identifiers and placement + +Each adapter defines how these identifiers and message relationships map to its platform: + +##### Jira + +The adapter retains the issue ID or key, invoking comment ID, root comment ID, acknowledgement comment ID, and outcome comment ID. It posts the acknowledgement and outcome as separate replies to the same root comment. + +##### GitHub + +The adapter retains the repository, pull-request number, invoking comment ID, root review-comment ID when the invocation is in a review thread, acknowledgement message ID, and outcome message ID. In a review thread, the acknowledgement and outcome both reply to the root review comment. For ordinary issue or pull-request comments, they are separate timeline comments on the pull request. + +##### Discord + +The adapter retains the thread or channel ID, invoking message ID, acknowledgement message ID, and outcome message ID. The acknowledgement replies to the invoking message, and the outcome replies to the acknowledgement. + +##### Teams + +The adapter retains the team and channel or chat ID, root conversation-message ID, invoking message ID, acknowledgement message ID, and outcome message ID. The acknowledgement and outcome are separate replies in the same root conversation. + +### Open questions + +#### Shared questions -- Which T3 outputs are rendered on the external platform: only the final answer, or also intermediate updates, tool results, attachments, and generated artifacts? -- Does the adapter post the result as a reply, create a new comment or message, or update a message created earlier for the same turn? - How does each adapter translate T3 formatting and content into the external platform's supported format and size limits? - What happens when a response cannot be posted, is posted only partially, or must be retried? - How are responses to concurrent turns ordered or labeled when they appear in the same external interaction? From f076f9c8cbfb66beef1ad06284ae68c3d0e6456c Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 14:06:38 +0200 Subject: [PATCH 008/110] feat: finish processing --- docs/planning/ntsb-event-processing.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/planning/ntsb-event-processing.md b/docs/planning/ntsb-event-processing.md index a4c269104b97..52b19b372d51 100644 --- a/docs/planning/ntsb-event-processing.md +++ b/docs/planning/ntsb-event-processing.md @@ -89,8 +89,20 @@ Outbound processing adds the acknowledgement and final-outcome message IDs, toge ### Agreed decisions +Only the acknowledgement and the final answer, failure, timeout, or cancellation are rendered on the external platform. All other T3 events remain internal. + Each inbound event creates an immediate outbound acknowledgement. When its T3 thread ends, the adapter sends the final answer, failure, timeout, or cancellation as a new message after that acknowledgement, in the platform's native conversation scope. +#### Response format + +Acknowledgements and final messages are text. Adapters use the platform's Markdown-like formatting, including fenced code snippets when useful. + +T3 does not use interactive controls, permission requests, or multiple-choice prompts on external platforms. Any question is written as ordinary text. + +#### Delivery failures + +The adapter posts the result or error as the final message. If delivery fails for a recoverable reason, it retries; otherwise the original working message remains without a follow-up, and the user may start a new request. + #### Message identifiers and placement Each adapter defines how these identifiers and message relationships map to its platform: @@ -110,12 +122,3 @@ The adapter retains the thread or channel ID, invoking message ID, acknowledgeme ##### Teams The adapter retains the team and channel or chat ID, root conversation-message ID, invoking message ID, acknowledgement message ID, and outcome message ID. The acknowledgement and outcome are separate replies in the same root conversation. - -### Open questions - -#### Shared questions - -- How does each adapter translate T3 formatting and content into the external platform's supported format and size limits? -- What happens when a response cannot be posted, is posted only partially, or must be retried? -- How are responses to concurrent turns ordered or labeled when they appear in the same external interaction? -- Which T3 events are intentionally kept inside T3 rather than rendered externally? From 0315ca4e49f18411d57a74992f99aff0ac340d39 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 14:49:31 +0200 Subject: [PATCH 009/110] chore: kickstart architecture document --- docs/planning/ntsb-architecture.md | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/planning/ntsb-architecture.md diff --git a/docs/planning/ntsb-architecture.md b/docs/planning/ntsb-architecture.md new file mode 100644 index 000000000000..2043ed934088 --- /dev/null +++ b/docs/planning/ntsb-architecture.md @@ -0,0 +1,54 @@ +# NTBS architecture + +**Status:** exploratory planning + +This document defines the boundary between T3 and adapters for non-turn-based surfaces such as Jira, GitHub, Discord, and Teams. It explains which system retains which information and the shared path from an external event to a T3 result and back to the external platform. + +## Problem + +T3 clients are built around T3 data views such as threads, diffs, and projects. External platforms know none of those concepts. They only know their own messages, comments, conversations, and identifiers. + +An adapter therefore cannot rely on an external platform to retain T3 state, and T3 cannot infer where a later result belongs from its own thread data alone. The adapter must retain the link between its platform's event and the T3 work created from it. + +## Shared model + +An adapter receives a platform event, applies the trigger rules, captures the source snapshot, and creates a new T3 thread. It retains the platform identifiers and the T3 identifiers created from that event. + +The adapter sends an acknowledgement to the external platform. When T3 reports the thread's final outcome, the adapter uses its retained record to post the final answer, failure, timeout, or cancellation in the correct place. + +T3 remains independent of the platform that produced the event. It owns its threads, messages, turns, execution state, worktrees, and branches. The adapter owns platform authentication, event delivery, source snapshots, platform identifiers, response placement, and platform-specific rendering. + +## Adapter record + +For each event that starts T3 work, the adapter needs a durable record containing: + +- the platform's source event ID and version; +- the source context and message or comment identifiers; +- the captured source snapshot; +- the T3 thread, user-message, and turn identifiers created from the event; +- the acknowledgement and final-message identifiers, when they have been posted; +- the delivery state for both outbound messages. + +This record lets the adapter avoid creating duplicate threads, resume after a restart, and deliver a later T3 outcome to the correct external location. + +## Shared flow + +1. The adapter receives an external event and decides whether it starts T3 work. +2. The adapter creates or reuses its durable record and captures the source snapshot. +3. The adapter asks T3 to create a new thread and retains the resulting T3 identifiers. +4. The adapter posts the acknowledgement and records its message identifier. +5. T3 reports the thread's final outcome. +6. The adapter finds the corresponding record, posts the outcome, and records the result of that delivery. + +## Decisions still needed + +- Define the request from an adapter to T3: the source snapshot, target project, starting revision, and execution settings. +- Define how an adapter receives thread outcomes from T3, including replay after an adapter restart. +- Define the exact idempotency rules for source events, edits, retries, late delivery, and out-of-order delivery. +- Choose the durable storage implementation and retention policy for adapter records. +- Define what happens when the source object is deleted, closed, archived, or otherwise changes while T3 work is running. + +## Related documents + +- [ntsb.md](./ntsb.md) records the overall scope and agreed decisions. +- [ntsb-event-processing.md](./ntsb-event-processing.md) defines inbound triggers and outbound messages on each platform. From bb095fb3ec013e9f1698127b8ea992be80affa16 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 16:25:32 +0200 Subject: [PATCH 010/110] chore: settle on generic definition --- docs/planning/ntsb-architecture.md | 103 ++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 16 deletions(-) diff --git a/docs/planning/ntsb-architecture.md b/docs/planning/ntsb-architecture.md index 2043ed934088..387158ecefc9 100644 --- a/docs/planning/ntsb-architecture.md +++ b/docs/planning/ntsb-architecture.md @@ -18,27 +18,97 @@ The adapter sends an acknowledgement to the external platform. When T3 reports t T3 remains independent of the platform that produced the event. It owns its threads, messages, turns, execution state, worktrees, and branches. The adapter owns platform authentication, event delivery, source snapshots, platform identifiers, response placement, and platform-specific rendering. -## Adapter record +The adapter keeps the full record for its platform. T3 does not receive or interpret the adapter's source-event data or response destination. The adapter detects repeated deliveries, creates the snapshot, and asks T3 to start work. T3 receives the snapshot, returns the new thread, message, and turn IDs, and later reports the final outcome. The adapter adds those T3 values to its own record and posts the result on its platform. -For each event that starts T3 work, the adapter needs a durable record containing: +## Event lifecycle -- the platform's source event ID and version; -- the source context and message or comment identifiers; -- the captured source snapshot; -- the T3 thread, user-message, and turn identifiers created from the event; -- the acknowledgement and final-message identifiers, when they have been posted; -- the delivery state for both outbound messages. +Starting from an external event, this happens: -This record lets the adapter avoid creating duplicate threads, resume after a restart, and deliver a later T3 outcome to the correct external location. +1. The adapter accepts an external event that matches a trigger. It creates an adapter record containing the source identifiers, response destination, and captured snapshot. +2. The adapter asks T3 to create a new thread from that snapshot. +3. T3 creates the thread, user message, and turn. The adapter adds those IDs to its record. +4. The adapter posts the acknowledgement and adds its message ID to the record. +5. T3 produces the final answer, failure, timeout, or cancellation for that turn. +6. The adapter finds the record from the T3 IDs, posts the final message at its stored response destination, and adds the final-message ID to the record. -## Shared flow +## Adapter record -1. The adapter receives an external event and decides whether it starts T3 work. -2. The adapter creates or reuses its durable record and captures the source snapshot. -3. The adapter asks T3 to create a new thread and retains the resulting T3 identifiers. -4. The adapter posts the acknowledgement and records its message identifier. -5. T3 reports the thread's final outcome. -6. The adapter finds the corresponding record, posts the outcome, and records the result of that delivery. +Before it asks T3 to create a thread, the adapter record contains: + +- the adapter's own source-event data; +- the adapter's own response destination; +- the captured source snapshot as a string; + +After T3 creates the thread, the adapter adds the T3 thread, user-message, and turn IDs. + +After it posts the acknowledgement and final response, the adapter adds their message IDs. + +The record retains the accepted source event, the new T3 thread it starts, and the messages the adapter sends for that thread. + +`NtsbEventRecord` is a TypeScript pattern for adapter code, not a shared storage format. Each adapter defines, validates, and stores its own source-event data and response destination. + +```ts +/** + * Tracks the lifecycle of external inbound events, such as comments or messages, that trigger T3 work. + * External applications have no relationship to T3, and vice versa. The adapter relates events in one to the other. + */ +type NtsbEventRecord = { + /** Adapter-defined information about the inbound event. */ + source: SourceEvent; + /** Adapter-defined information about where replies belong. */ + responseDestination: ResponseDestination; + /** The captured source text used to create T3's first user message. */ + snapshot: string; + /** The T3 IDs created after the adapter starts work. */ + t3?: { + /** The T3 thread created from the source event. */ + threadId: string; + /** The first T3 user message created from the snapshot. */ + userMessageId: string; + /** The T3 turn started from that message. */ + turnId: string; + }; + /** The external acknowledgement message posted by the adapter. */ + acknowledgementMessageId?: string; + /** The external final message posted by the adapter. */ + finalMessageId?: string; +}; +``` + +The optional fields in this initial type represent different points in the event lifecycle. They must be replaced with separate record shapes once those lifecycle transitions have been fully defined. + +## Jira example + +A user adds top-level Jira comment `10401` on issue `T3-123`: `@agent investigate the failed build`. The adapter accepts source event `jira-event-1`, version `1`, and stores this record before asking T3 to do anything: + +```ts +{ + source: { + platform: "jira", + eventId: "jira-event-1", + version: "1", + contextId: "T3-123", + messageId: "10401", + }, + responseDestination: { + contextId: "T3-123", + parentMessageId: "10401", + }, + snapshot: "@agent investigate the failed build", +} +``` + +When T3 creates the work, the adapter adds its IDs: + +```ts +t3: { + threadId: "thread-1", + userMessageId: "message-1", + turnId: "turn-1", +} +``` + +The adapter posts an acknowledgement as a reply to Jira comment `10401` and adds `acknowledgementMessageId: "10402"`. When T3 produces the final result for `turn-1`, the adapter finds this record, posts another reply to comment `10401`, and adds `finalMessageId: "10403"`. ## Decisions still needed @@ -46,6 +116,7 @@ This record lets the adapter avoid creating duplicate threads, resume after a re - Define how an adapter receives thread outcomes from T3, including replay after an adapter restart. - Define the exact idempotency rules for source events, edits, retries, late delivery, and out-of-order delivery. - Choose the durable storage implementation and retention policy for adapter records. +- Define separate record shapes for each lifecycle transition, replacing the optional fields in `NtsbEventRecord`. - Define what happens when the source object is deleted, closed, archived, or otherwise changes while T3 work is running. ## Related documents From 2e8830b51153755535ef5bfd1ac574341fe09b3d Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 17:05:50 +0200 Subject: [PATCH 011/110] chore: document lifecycle --- docs/planning/ntsb-architecture.md | 105 +++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/docs/planning/ntsb-architecture.md b/docs/planning/ntsb-architecture.md index 387158ecefc9..036f36b6613a 100644 --- a/docs/planning/ntsb-architecture.md +++ b/docs/planning/ntsb-architecture.md @@ -18,7 +18,7 @@ The adapter sends an acknowledgement to the external platform. When T3 reports t T3 remains independent of the platform that produced the event. It owns its threads, messages, turns, execution state, worktrees, and branches. The adapter owns platform authentication, event delivery, source snapshots, platform identifiers, response placement, and platform-specific rendering. -The adapter keeps the full record for its platform. T3 does not receive or interpret the adapter's source-event data or response destination. The adapter detects repeated deliveries, creates the snapshot, and asks T3 to start work. T3 receives the snapshot, returns the new thread, message, and turn IDs, and later reports the final outcome. The adapter adds those T3 values to its own record and posts the result on its platform. +The adapter keeps the full record for its platform. T3 does not receive or interpret platform data. The adapter detects repeated deliveries, creates the snapshot, and asks T3 to start work. T3 receives the snapshot, returns the new thread, message, and turn IDs, and later reports the final outcome. The adapter adds those T3 values to its own record and posts the result on its platform. ## Event lifecycle @@ -35,32 +35,52 @@ Starting from an external event, this happens: Before it asks T3 to create a thread, the adapter record contains: -- the adapter's own source-event data; -- the adapter's own response destination; +- the adapter's platform data; - the captured source snapshot as a string; After T3 creates the thread, the adapter adds the T3 thread, user-message, and turn IDs. After it posts the acknowledgement and final response, the adapter adds their message IDs. -The record retains the accepted source event, the new T3 thread it starts, and the messages the adapter sends for that thread. +The event retains the accepted source event, the new T3 thread it starts, and the messages the adapter sends for that thread. -`NtsbEventRecord` is a TypeScript pattern for adapter code, not a shared storage format. Each adapter defines, validates, and stores its own source-event data and response destination. +`NtsbEvent` is a TypeScript pattern for adapter code, not a shared storage format. Each adapter defines, validates, and stores its own platform data. ```ts +/** All data that is specific to the external platform. */ +type PlatformData = { + /** Information about the inbound event. */ + source: Source; + /** Information about where replies belong. */ + responseDestination: ResponseDestination; +}; + /** * Tracks the lifecycle of external inbound events, such as comments or messages, that trigger T3 work. * External applications have no relationship to T3, and vice versa. The adapter relates events in one to the other. */ -type NtsbEventRecord = { - /** Adapter-defined information about the inbound event. */ - source: SourceEvent; - /** Adapter-defined information about where replies belong. */ - responseDestination: ResponseDestination; +type NtsbEvent

> = + | NtsbEventAccepted

+ | NtsbEventThreadStarted

+ | NtsbEventAcknowledgementPosted

+ | NtsbEventOutcomeAvailable

+ | NtsbEventResponsePosted

; + +type NtsbEventBase

> = { + /** Adapter-defined data for the external platform. T3 does not inspect it. */ + platformData: P; /** The captured source text used to create T3's first user message. */ snapshot: string; +}; + +type NtsbEventAccepted

> = NtsbEventBase

& { + /** The adapter has accepted the inbound event but has not started T3 work. */ + state: "accepted"; +}; + +type NtsbEventWithThread

> = NtsbEventBase

& { /** The T3 IDs created after the adapter starts work. */ - t3?: { + t3: { /** The T3 thread created from the source event. */ threadId: string; /** The first T3 user message created from the snapshot. */ @@ -68,14 +88,41 @@ type NtsbEventRecord = { /** The T3 turn started from that message. */ turnId: string; }; - /** The external acknowledgement message posted by the adapter. */ - acknowledgementMessageId?: string; - /** The external final message posted by the adapter. */ - finalMessageId?: string; }; + +type NtsbEventThreadStarted

> = NtsbEventWithThread

& { + /** T3 has created the new thread from the source snapshot. */ + state: "threadStarted"; +}; + +type NtsbEventWithAcknowledgement

> = + NtsbEventWithThread

& { + /** The external acknowledgement message posted by the adapter. */ + acknowledgementMessageId: string; + }; + +type NtsbEventAcknowledgementPosted

> = + NtsbEventWithAcknowledgement

& { + /** The adapter has posted the acknowledgement. */ + state: "acknowledgementPosted"; + }; + +type NtsbEventOutcomeAvailable

> = + NtsbEventWithAcknowledgement

& { + /** T3 has produced a final outcome for the turn. */ + state: "outcomeAvailable"; + }; + +type NtsbEventResponsePosted

> = + NtsbEventWithAcknowledgement

& { + /** The adapter has posted T3's final response. */ + state: "responsePosted"; + /** The external final message posted by the adapter. */ + finalMessageId: string; + }; ``` -The optional fields in this initial type represent different points in the event lifecycle. They must be replaced with separate record shapes once those lifecycle transitions have been fully defined. +TODO: Define error and retry lifecycle states when adapter behaviour is tested. ## Jira example @@ -83,16 +130,18 @@ A user adds top-level Jira comment `10401` on issue `T3-123`: `@agent investigat ```ts { - source: { - platform: "jira", - eventId: "jira-event-1", - version: "1", - contextId: "T3-123", - messageId: "10401", - }, - responseDestination: { - contextId: "T3-123", - parentMessageId: "10401", + state: "accepted", + platformData: { + source: { + eventId: "jira-event-1", + version: "1", + contextId: "T3-123", + messageId: "10401", + }, + responseDestination: { + contextId: "T3-123", + parentMessageId: "10401", + }, }, snapshot: "@agent investigate the failed build", } @@ -101,6 +150,7 @@ A user adds top-level Jira comment `10401` on issue `T3-123`: `@agent investigat When T3 creates the work, the adapter adds its IDs: ```ts +state: "threadStarted", t3: { threadId: "thread-1", userMessageId: "message-1", @@ -108,7 +158,7 @@ t3: { } ``` -The adapter posts an acknowledgement as a reply to Jira comment `10401` and adds `acknowledgementMessageId: "10402"`. When T3 produces the final result for `turn-1`, the adapter finds this record, posts another reply to comment `10401`, and adds `finalMessageId: "10403"`. +The adapter posts an acknowledgement as a reply to Jira comment `10401`, changes the state to `"acknowledgementPosted"`, and adds `acknowledgementMessageId: "10402"`. When T3 produces the final result for `turn-1`, the state becomes `"outcomeAvailable"`. The adapter then posts another reply to comment `10401`, changes the state to `"responsePosted"`, and adds `finalMessageId: "10403"`. ## Decisions still needed @@ -116,7 +166,6 @@ The adapter posts an acknowledgement as a reply to Jira comment `10401` and adds - Define how an adapter receives thread outcomes from T3, including replay after an adapter restart. - Define the exact idempotency rules for source events, edits, retries, late delivery, and out-of-order delivery. - Choose the durable storage implementation and retention policy for adapter records. -- Define separate record shapes for each lifecycle transition, replacing the optional fields in `NtsbEventRecord`. - Define what happens when the source object is deleted, closed, archived, or otherwise changes while T3 work is running. ## Related documents From a4845931e9f4b8c96ba738152cead9c1266f9f57 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 17:51:25 +0200 Subject: [PATCH 012/110] feat: define ntbs architeture --- docs/planning/ntsb-architecture.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/planning/ntsb-architecture.md b/docs/planning/ntsb-architecture.md index 036f36b6613a..20d231f8b617 100644 --- a/docs/planning/ntsb-architecture.md +++ b/docs/planning/ntsb-architecture.md @@ -18,7 +18,19 @@ The adapter sends an acknowledgement to the external platform. When T3 reports t T3 remains independent of the platform that produced the event. It owns its threads, messages, turns, execution state, worktrees, and branches. The adapter owns platform authentication, event delivery, source snapshots, platform identifiers, response placement, and platform-specific rendering. -The adapter keeps the full record for its platform. T3 does not receive or interpret platform data. The adapter detects repeated deliveries, creates the snapshot, and asks T3 to start work. T3 receives the snapshot, returns the new thread, message, and turn IDs, and later reports the final outcome. The adapter adds those T3 values to its own record and posts the result on its platform. +The adapter keeps the full record for its platform. T3 does not receive or interpret platform data. The adapter makes sure the same platform message does not start T3 work twice, creates the snapshot, and asks T3 to start work. T3 receives the snapshot, returns the new thread, message, and turn IDs, and later reports the final outcome. The adapter adds those T3 values to its own record and posts the result on its platform. + +Storage and retention are adapter implementation details, not architecture decisions. Platform-specific edge cases, such as a source item being deleted or closed while T3 is working, also belong to the adapter implementation phase. + +## Passing T3 context + +An incoming platform event carries both platform data and the T3 context needed to start work, such as the project, revision, and execution context. The adapter forwards that T3 context to T3 when it creates the new thread. + +`NtsbEvent` does not retain the project, revision, or execution context as lifecycle data. Once T3 creates the thread, T3 owns that information. Keeping copies in `NtsbEvent` would require the adapter to keep them in sync with T3. + +## Receiving T3 outcomes + +Adapters subscribe to T3's event log, like other T3 consumers. After T3 starts a thread, `NtsbEvent` contains its turn ID. When the adapter receives the final outcome for that turn from the event log, it uses the same `NtsbEvent` to post the result on the external platform. ## Event lifecycle @@ -160,14 +172,6 @@ t3: { The adapter posts an acknowledgement as a reply to Jira comment `10401`, changes the state to `"acknowledgementPosted"`, and adds `acknowledgementMessageId: "10402"`. When T3 produces the final result for `turn-1`, the state becomes `"outcomeAvailable"`. The adapter then posts another reply to comment `10401`, changes the state to `"responsePosted"`, and adds `finalMessageId: "10403"`. -## Decisions still needed - -- Define the request from an adapter to T3: the source snapshot, target project, starting revision, and execution settings. -- Define how an adapter receives thread outcomes from T3, including replay after an adapter restart. -- Define the exact idempotency rules for source events, edits, retries, late delivery, and out-of-order delivery. -- Choose the durable storage implementation and retention policy for adapter records. -- Define what happens when the source object is deleted, closed, archived, or otherwise changes while T3 work is running. - ## Related documents - [ntsb.md](./ntsb.md) records the overall scope and agreed decisions. From 4f0ac101cd8ecc62294c6b3ace2b8114a4021300 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 17:52:13 +0200 Subject: [PATCH 013/110] feat: rename ntsb -> ntbs --- docs/planning/feedback.md | 60 ------------------- ...b-architecture.md => ntbs-architecture.md} | 0 ...processing.md => ntbs-event-processing.md} | 0 docs/planning/{ntsb.md => ntbs.md} | 0 4 files changed, 60 deletions(-) delete mode 100644 docs/planning/feedback.md rename docs/planning/{ntsb-architecture.md => ntbs-architecture.md} (100%) rename docs/planning/{ntsb-event-processing.md => ntbs-event-processing.md} (100%) rename docs/planning/{ntsb.md => ntbs.md} (100%) diff --git a/docs/planning/feedback.md b/docs/planning/feedback.md deleted file mode 100644 index b465d3ffd3cb..000000000000 --- a/docs/planning/feedback.md +++ /dev/null @@ -1,60 +0,0 @@ -In ntsb-event-processing.md: - -- “authorized request” -- “enabled interaction” -- “accepted request/event” -- “qualifying event” -- “request for the agent” -- “new explicit invocation” -- “source snapshot permitted by the access check” -- “pending turn record” — especially wrong now that we decided not to - queue NTBS work - -- “stable source event identity” — this is appropriately a TODO, but - should be described consistently - -- “external interaction” -- “response destination” -- “correlation record” -- “shared-resource coordination” -- “provider execution” - -The most distracting ones are qualifying, authorized, accepted, and -enabled. I’d replace them with concrete language such as: - -- “an event that matches one of the triggers below” -- “an event accepted after webhook/authentication checks” -- “the external object or conversation that contains the event” -- “the exact comment or message to which T3 posts the answer” - -In ntsb.md: - -- “canonical” event log -- “explicit subset” of commands/events/state -- “projected state” -- “limited clients” -- “source-event translation” -- “accepted external event” -- “agent turn” -- “captured source snapshot” -- “response target” -- “correlation record” -- “T3-only context” -- “external interaction” -- “lifecycle semantics” -- “deliberately omitted or unsupported” - -There are also two concrete leftovers: - -- The open question at line 64 still says events may be “recorded - without starting work,” even though we moved that out of scope. - -- Line 70 is a decision—“NTBS does not target existing execution - threads”—but it is sitting among open questions and should not be - phrased as one. - -The biggest cleanup would be to remove qualifying, authorized, and -accepted wherever they are not carrying a distinct security or lifecycle -meaning, then define the few terms we actually need: external event, -external interaction, captured snapshot, T3 thread, and response -destination. diff --git a/docs/planning/ntsb-architecture.md b/docs/planning/ntbs-architecture.md similarity index 100% rename from docs/planning/ntsb-architecture.md rename to docs/planning/ntbs-architecture.md diff --git a/docs/planning/ntsb-event-processing.md b/docs/planning/ntbs-event-processing.md similarity index 100% rename from docs/planning/ntsb-event-processing.md rename to docs/planning/ntbs-event-processing.md diff --git a/docs/planning/ntsb.md b/docs/planning/ntbs.md similarity index 100% rename from docs/planning/ntsb.md rename to docs/planning/ntbs.md From ce4864d9db476985e9aeb4d7a13436cde715a60e Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 5 Aug 2026 17:54:29 +0200 Subject: [PATCH 014/110] fix: ntsb -> ntbs --- docs/planning/ntbs-architecture.md | 48 +++++++++++++------------- docs/planning/ntbs-event-processing.md | 2 +- docs/planning/ntbs.md | 4 +-- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/planning/ntbs-architecture.md b/docs/planning/ntbs-architecture.md index 20d231f8b617..b47cf90e0bdc 100644 --- a/docs/planning/ntbs-architecture.md +++ b/docs/planning/ntbs-architecture.md @@ -26,11 +26,11 @@ Storage and retention are adapter implementation details, not architecture decis An incoming platform event carries both platform data and the T3 context needed to start work, such as the project, revision, and execution context. The adapter forwards that T3 context to T3 when it creates the new thread. -`NtsbEvent` does not retain the project, revision, or execution context as lifecycle data. Once T3 creates the thread, T3 owns that information. Keeping copies in `NtsbEvent` would require the adapter to keep them in sync with T3. +`NtbsEvent` does not retain the project, revision, or execution context as lifecycle data. Once T3 creates the thread, T3 owns that information. Keeping copies in `NtbsEvent` would require the adapter to keep them in sync with T3. ## Receiving T3 outcomes -Adapters subscribe to T3's event log, like other T3 consumers. After T3 starts a thread, `NtsbEvent` contains its turn ID. When the adapter receives the final outcome for that turn from the event log, it uses the same `NtsbEvent` to post the result on the external platform. +Adapters subscribe to T3's event log, like other T3 consumers. After T3 starts a thread, `NtbsEvent` contains its turn ID. When the adapter receives the final outcome for that turn from the event log, it uses the same `NtbsEvent` to post the result on the external platform. ## Event lifecycle @@ -56,7 +56,7 @@ After it posts the acknowledgement and final response, the adapter adds their me The event retains the accepted source event, the new T3 thread it starts, and the messages the adapter sends for that thread. -`NtsbEvent` is a TypeScript pattern for adapter code, not a shared storage format. Each adapter defines, validates, and stores its own platform data. +`NtbsEvent` is a TypeScript pattern for adapter code, not a shared storage format. Each adapter defines, validates, and stores its own platform data. ```ts /** All data that is specific to the external platform. */ @@ -71,26 +71,26 @@ type PlatformData = { * Tracks the lifecycle of external inbound events, such as comments or messages, that trigger T3 work. * External applications have no relationship to T3, and vice versa. The adapter relates events in one to the other. */ -type NtsbEvent

> = - | NtsbEventAccepted

- | NtsbEventThreadStarted

- | NtsbEventAcknowledgementPosted

- | NtsbEventOutcomeAvailable

- | NtsbEventResponsePosted

; - -type NtsbEventBase

> = { +type NtbsEvent

> = + | NtbsEventAccepted

+ | NtbsEventThreadStarted

+ | NtbsEventAcknowledgementPosted

+ | NtbsEventOutcomeAvailable

+ | NtbsEventResponsePosted

; + +type NtbsEventBase

> = { /** Adapter-defined data for the external platform. T3 does not inspect it. */ platformData: P; /** The captured source text used to create T3's first user message. */ snapshot: string; }; -type NtsbEventAccepted

> = NtsbEventBase

& { +type NtbsEventAccepted

> = NtbsEventBase

& { /** The adapter has accepted the inbound event but has not started T3 work. */ state: "accepted"; }; -type NtsbEventWithThread

> = NtsbEventBase

& { +type NtbsEventWithThread

> = NtbsEventBase

& { /** The T3 IDs created after the adapter starts work. */ t3: { /** The T3 thread created from the source event. */ @@ -102,31 +102,31 @@ type NtsbEventWithThread

> = NtsbEventBa }; }; -type NtsbEventThreadStarted

> = NtsbEventWithThread

& { +type NtbsEventThreadStarted

> = NtbsEventWithThread

& { /** T3 has created the new thread from the source snapshot. */ state: "threadStarted"; }; -type NtsbEventWithAcknowledgement

> = - NtsbEventWithThread

& { +type NtbsEventWithAcknowledgement

> = + NtbsEventWithThread

& { /** The external acknowledgement message posted by the adapter. */ acknowledgementMessageId: string; }; -type NtsbEventAcknowledgementPosted

> = - NtsbEventWithAcknowledgement

& { +type NtbsEventAcknowledgementPosted

> = + NtbsEventWithAcknowledgement

& { /** The adapter has posted the acknowledgement. */ state: "acknowledgementPosted"; }; -type NtsbEventOutcomeAvailable

> = - NtsbEventWithAcknowledgement

& { +type NtbsEventOutcomeAvailable

> = + NtbsEventWithAcknowledgement

& { /** T3 has produced a final outcome for the turn. */ state: "outcomeAvailable"; }; -type NtsbEventResponsePosted

> = - NtsbEventWithAcknowledgement

& { +type NtbsEventResponsePosted

> = + NtbsEventWithAcknowledgement

& { /** The adapter has posted T3's final response. */ state: "responsePosted"; /** The external final message posted by the adapter. */ @@ -174,5 +174,5 @@ The adapter posts an acknowledgement as a reply to Jira comment `10401`, changes ## Related documents -- [ntsb.md](./ntsb.md) records the overall scope and agreed decisions. -- [ntsb-event-processing.md](./ntsb-event-processing.md) defines inbound triggers and outbound messages on each platform. +- [ntbs.md](./ntbs.md) records the overall scope and agreed decisions. +- [ntbs-event-processing.md](./ntbs-event-processing.md) defines inbound triggers and outbound messages on each platform. diff --git a/docs/planning/ntbs-event-processing.md b/docs/planning/ntbs-event-processing.md index 52b19b372d51..22391a340d95 100644 --- a/docs/planning/ntbs-event-processing.md +++ b/docs/planning/ntbs-event-processing.md @@ -1,4 +1,4 @@ -# NTSB event processing +# NTBS event processing **Status:** exploratory planning diff --git a/docs/planning/ntbs.md b/docs/planning/ntbs.md index 0ecbb15f3814..67b2f3f22981 100644 --- a/docs/planning/ntbs.md +++ b/docs/planning/ntbs.md @@ -34,13 +34,13 @@ Implementation is out of scope for this planning stage. ## Proposal: A triggering event creates a new thread -Each event that matches a trigger creates a new T3 thread from the event and its captured source snapshot; the detailed trigger, processing, concurrency, and response-routing rules are defined in [ntsb-event-processing.md](./ntsb-event-processing.md). +Each event that matches a trigger creates a new T3 thread from the event and its captured source snapshot; the detailed trigger, processing, concurrency, and response-routing rules are defined in [ntbs-event-processing.md](./ntbs-event-processing.md). ## Agreed decisions ### Which external messages or state changes trigger an agent turn, and which are ignored or recorded without starting work? -The platform-specific trigger forms, ignored events, thread creation, and response routing are defined in [ntsb-event-processing.md](./ntsb-event-processing.md). +The platform-specific trigger forms, ignored events, thread creation, and response routing are defined in [ntbs-event-processing.md](./ntbs-event-processing.md). ### What identifies the same external interaction for correlation and projection? From cacc425d6995864e1e17f8ff2361c2808ac5808c Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 6 Aug 2026 15:57:27 +0200 Subject: [PATCH 015/110] feat: write plan --- docs/planning/ntbs-plan.md | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/planning/ntbs-plan.md diff --git a/docs/planning/ntbs-plan.md b/docs/planning/ntbs-plan.md new file mode 100644 index 000000000000..802de7c5bdea --- /dev/null +++ b/docs/planning/ntbs-plan.md @@ -0,0 +1,63 @@ +# NTBS implementation plan + +**Status:** exploratory planning + +## 1. Understand the existing mechanics + +Read the orchestration command definitions, the orchestration engine service, and the WebSocket turn-start handling to understand how T3 creates threads, prepares worktrees, starts turns, persists events, and exposes those events to consumers. + +Then follow the current Jira path from the webhook route and payload parser through the Jira bridge, delivery store, and Jira API client. This provides concrete examples of inbound event handling, platform-owned persistence, T3 command dispatch, acknowledgement delivery, outcome detection, and outbound response placement. + +The current Jira bridge is a reference, not the desired architecture. It contains platform-independent behavior that should move into the shared NTBS implementation, and it currently reuses existing threads instead of creating a new thread for every accepted event. + +## 2. Build the platform-agnostic NTBS implementation + +Create `apps/server/src/ntbs` for the shared lifecycle model, adapter contract, and workflow service. + +First, extract the existing create-thread, prepare-worktree, and start-turn mechanic from the WebSocket handler into a reusable orchestration service. Both native T3 clients and NTBS workflows should call this service so thread creation behaves consistently regardless of where the request originated. + +Define an adapter contract that leaves platform data opaque to the shared workflow. Each adapter supplies persistence, duplicate prevention, acknowledgement delivery, final-response delivery, and the platform-specific data needed to place those messages. + +Implement the shared workflow: + +1. Accept the snapshot, T3 context, and opaque platform data from an adapter. +2. Persist the accepted lifecycle state before starting T3 work. +3. Create a new T3 thread and worktree, start its first turn, and retain the resulting T3 identifiers. +4. Ask the adapter to post the acknowledgement and retain its platform message identifier. +5. Consume T3 events, including replay after a restart, and identify the final outcome for the recorded work. +6. Load the final assistant text or failure information and ask the adapter to post the final message. +7. Persist every lifecycle transition so interrupted processing can resume safely. + +Confirm when the T3 turn ID becomes available during this work. The current command path knows the thread and user-message IDs immediately but discovers the turn ID later. The implementation and lifecycle types must represent that sequence accurately. + +Test the shared workflow with an in-memory adapter implementation before connecting it to a real platform. The tests should cover successful completion, failure, duplicate delivery, restart recovery, and concurrent events. + +## 3. Port Jira onto the shared implementation + +Keep Jira webhook verification, payload parsing, trigger recognition, Jira identifiers, and Jira API calls inside the Jira adapter. + +Replace the shared workflow currently embedded in the Jira bridge with an implementation of the NTBS adapter contract. Adapt the Jira delivery store to persist the NTBS lifecycle together with Jira-specific source and response-destination data. + +Change Jira processing so every accepted event creates a new T3 thread. Preserve the agreed outbound behavior: post an acknowledgement for the invoking comment, then post the final answer, failure, timeout, or cancellation as a separate reply in the same Jira comment scope. + +Update the Jira tests to prove trigger handling, duplicate prevention, lifecycle recovery, new-thread creation, acknowledgement placement, final-response placement, and concurrent invocations. + +# Notes + +In `packages/contracts/src/orchestration.ts` we can find the schema `ThreadTurnStartBootstrapCreateThread`. + +The schema wants: + +- `projectId` (project should be inferred by discord/jira/etc) +- `title` (generated somewhere) +- `modelSelection` (some model) +- `runtimeMode` (permissions) +- `interactionMode` (apparently default vs plan) +- `branch` (git branch?) +- `worktreePath` (where is it on filesystem) + +It is then used by the + +`ThreadTurnStartBootstrap` which has some optional data for running setup script, preparing worktrees which is then used by + +`ThreadTurnStartCommand` and `ClientThreadTurnStartCommand` (essentially the same type) From c6ff7a6e037d289971cd02ea1ded8237c2e16dd6 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 7 Aug 2026 16:32:59 +0200 Subject: [PATCH 016/110] feat: implement basic lifecycle types --- apps/server/src/ntbs/schemas.ts | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 apps/server/src/ntbs/schemas.ts diff --git a/apps/server/src/ntbs/schemas.ts b/apps/server/src/ntbs/schemas.ts new file mode 100644 index 000000000000..3f9b84d538c8 --- /dev/null +++ b/apps/server/src/ntbs/schemas.ts @@ -0,0 +1,68 @@ +/** + * Describes the platform-specific data of a + * Non-Turn-Based-Surface. + * + * When receiving an NTBS event (a comment, a message tagging + * a bot, etc) `source` and `responseDestination` hold the details + * necessary to process the what and why. + */ +type PlatformData = { + source: Source; + responseDestination: ResponseDestination; +}; + +type LifecycleEvent

= { + /** + * Each NTBSEvent carries the adapter-defined external data. + * T3 never inspects it. Only the adapter deals with it. + */ + platformData: P; + /** + * The captured source text used to send the first T3 user message. + * Platform-independent. + */ + snapshot: string; +}; + +type ThreadEvent

= LifecycleEvent

& { + /** The T3 IDs created by the adapter */ + t3Data: { + /** The T3 thread created by the lifecycle event */ + threadId: string; + }; +}; + +type RequestAccepted

= LifecycleEvent

& { + state: "request.accepted"; +}; + +type ThreadStarted

= ThreadEvent

& { + /** T3 has created the new thread from the source snapshot */ + state: "thread.started"; +}; + +type ThreadStartedAcknowledgement

= ThreadEvent

& { + state: "thread.started.acknowledged"; + /** the external's platform identification of the acknowledgment message */ + acknowledgementMessageId: string; +}; + +type ResponseAvailable

= ThreadEvent

& { + state: "thread.response.available"; + /** the external's platform identification of the acknowledgment message */ + acknowledgementMessageId: string; +}; + +type ResponsePosted

= ThreadEvent

& { + state: "thread.response.posted"; + /** the external's platform identification of the acknowledgment message */ + acknowledgementMessageId: string; + responseMessageId: string; +}; + +type NTBSLifecycle

= + | RequestAccepted

+ | ThreadStarted

+ | ThreadStartedAcknowledgement

+ | ResponseAvailable

+ | ResponsePosted

; From d8f51d02da9f04c3fcf9e3f76219a079b5656c60 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 7 Aug 2026 17:23:44 +0200 Subject: [PATCH 017/110] feat: implement adapter context service --- apps/server/src/ntbs/adapter.ts | 34 +++++++++++++++++++++++++++++++++ apps/server/src/ntbs/schemas.ts | 19 +++++++++--------- 2 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/ntbs/adapter.ts diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts new file mode 100644 index 000000000000..393d43f15475 --- /dev/null +++ b/apps/server/src/ntbs/adapter.ts @@ -0,0 +1,34 @@ +import * as NTBS from "./schemas.ts"; +import { Context, Data, Effect } from "effect"; + +export class ThreadNotFound extends Data.TaggedError("ThreadNotFound") {} + +/** + * Generic error catcher, will be refined later + */ +export class AdapterError extends Data.TaggedError("AdapterError")<{ + readonly reason: string; +}> {} + +export interface NTBSAdapter

{ + readonly accept: ( + event: NTBS.RequestAccepted

, + ) => Effect.Effect<"accepted" | "duplicate", AdapterError>; + readonly save: (lifecycleEvent: NTBS.NTBSLifecycle

) => Effect.Effect; + readonly postAcknowledgement: ( + event: NTBS.ThreadStarted

, + ) => Effect.Effect; + readonly postResponse: ( + event: NTBS.ResponseAvailable

, + text: string, + ) => Effect.Effect; + readonly findByThreadId: ( + threadId: string, + ) => Effect.Effect< + Exclude, NTBS.RequestAccepted

>, + ThreadNotFound | AdapterError + >; +} + +export const makeNTBSAdapter =

(key: string) => + Context.Service>(key); diff --git a/apps/server/src/ntbs/schemas.ts b/apps/server/src/ntbs/schemas.ts index 3f9b84d538c8..c409874d464c 100644 --- a/apps/server/src/ntbs/schemas.ts +++ b/apps/server/src/ntbs/schemas.ts @@ -6,12 +6,12 @@ * a bot, etc) `source` and `responseDestination` hold the details * necessary to process the what and why. */ -type PlatformData = { +export type PlatformData = { source: Source; responseDestination: ResponseDestination; }; -type LifecycleEvent

= { +export type LifecycleEvent

= { /** * Each NTBSEvent carries the adapter-defined external data. * T3 never inspects it. Only the adapter deals with it. @@ -24,43 +24,42 @@ type LifecycleEvent

= { snapshot: string; }; -type ThreadEvent

= LifecycleEvent

& { - /** The T3 IDs created by the adapter */ +export type ThreadEvent

= LifecycleEvent

& { t3Data: { /** The T3 thread created by the lifecycle event */ threadId: string; }; }; -type RequestAccepted

= LifecycleEvent

& { +export type RequestAccepted

= LifecycleEvent

& { state: "request.accepted"; }; -type ThreadStarted

= ThreadEvent

& { +export type ThreadStarted

= ThreadEvent

& { /** T3 has created the new thread from the source snapshot */ state: "thread.started"; }; -type ThreadStartedAcknowledgement

= ThreadEvent

& { +export type ThreadStartedAcknowledgement

= ThreadEvent

& { state: "thread.started.acknowledged"; /** the external's platform identification of the acknowledgment message */ acknowledgementMessageId: string; }; -type ResponseAvailable

= ThreadEvent

& { +export type ResponseAvailable

= ThreadEvent

& { state: "thread.response.available"; /** the external's platform identification of the acknowledgment message */ acknowledgementMessageId: string; }; -type ResponsePosted

= ThreadEvent

& { +export type ResponsePosted

= ThreadEvent

& { state: "thread.response.posted"; /** the external's platform identification of the acknowledgment message */ acknowledgementMessageId: string; responseMessageId: string; }; -type NTBSLifecycle

= +export type NTBSLifecycle

= | RequestAccepted

| ThreadStarted

| ThreadStartedAcknowledgement

From e240d6a33c6b16ba2036ed181e9723becbd4fdaf Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 7 Aug 2026 18:23:21 +0200 Subject: [PATCH 018/110] feat: work on processor --- apps/server/src/ntbs/processor.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/server/src/ntbs/processor.ts diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts new file mode 100644 index 000000000000..4462eebbbd36 --- /dev/null +++ b/apps/server/src/ntbs/processor.ts @@ -0,0 +1,29 @@ +import type { OrchestrationEvent, ProjectId } from "@t3tools/contracts"; +import type * as NTBS from "./schemas.ts"; +import { Data, Effect, Scope } from "effect"; + +export type T3Context = { + readonly projectId: ProjectId; + readonly revision: string; +}; + +export type ProcessorEvent

= + | { + readonly source: "adapter"; + readonly event: NTBS.LifecycleEvent

; + readonly t3Context: T3Context; + } + | { + readonly source: "t3"; + readonly event: OrchestrationEvent; + }; + +export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ + reason: string; +}> {} + +export interface NTBSProcessor

{ + readonly process: (event: ProcessorEvent

) => Effect.Effect; + + readonly start: () => Effect.Effect; +} From 06aa6a098730f2438834f28ca4d55f82096a9a29 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 7 Aug 2026 20:29:16 +0200 Subject: [PATCH 019/110] feat: processor types --- apps/server/src/ntbs/processor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 4462eebbbd36..50ff4ad11ff3 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,6 +1,6 @@ import type { OrchestrationEvent, ProjectId } from "@t3tools/contracts"; import type * as NTBS from "./schemas.ts"; -import { Data, Effect, Scope } from "effect"; +import { Context, Data, Effect, Scope } from "effect"; export type T3Context = { readonly projectId: ProjectId; @@ -27,3 +27,6 @@ export interface NTBSProcessor

{ readonly start: () => Effect.Effect; } + +export const makeNTBSProcessor =

(key: string) => + Context.Service>(key); From b9ddcf03b4a4da38d3651ca698efb0bc9495001c Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 7 Aug 2026 21:57:30 +0200 Subject: [PATCH 020/110] add inbout and outbound processor function --- apps/server/src/ntbs/processor.ts | 42 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 50ff4ad11ff3..a41bcc6d3725 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,6 +1,31 @@ -import type { OrchestrationEvent, ProjectId } from "@t3tools/contracts"; +import { type OrchestrationEvent, type ProjectId } from "@t3tools/contracts"; import type * as NTBS from "./schemas.ts"; -import { Context, Data, Effect, Scope } from "effect"; +import { Context, Data, Effect } from "effect"; + +/* + NTBS architectural description: + 1. Generic NTBS processor: + - Contains the shared workflow for every adapter. The business logic, regardless of the actual NTBS is identical + - Makes queries to the specific platform adapter + - Uses private T3-specific effect to create a fresh thread and worktree, then starts the turn with `snapshot` + - Saves `ThreadStarted` + - Posts to the NTBS platform through the adapter and saves `ThreadStartAcknowledgment` + + - Watches T3 events for completed work. + - Finds the adapter record by T3 thread ID, posts the final result + and saves `ResponseAvailable` and `ResponsePosted` + + 2. Platform handler + - Receives raw platform data (Jira, Discord, Github, Teams) + - Builds `RequestAccepted

and `T3Context` + - Calls the processor + + 3. Adapter + - Owns platform storage, duplicate detection and platform API calls + - Knows how to post acknowledgments and responses + - Knows how platform identifiers are represented + - Knows nothing about creating T3 threads or interpreting T3 events +*/ export type T3Context = { readonly projectId: ProjectId; @@ -10,7 +35,7 @@ export type T3Context = { export type ProcessorEvent

= | { readonly source: "adapter"; - readonly event: NTBS.LifecycleEvent

; + readonly event: NTBS.RequestAccepted

; readonly t3Context: T3Context; } | { @@ -25,8 +50,17 @@ export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ export interface NTBSProcessor

{ readonly process: (event: ProcessorEvent

) => Effect.Effect; - readonly start: () => Effect.Effect; + readonly subscribeToT3Events: () => Effect.Effect; } export const makeNTBSProcessor =

(key: string) => Context.Service>(key); + +declare const processAcceptedRequest:

( + request: NTBS.RequestAccepted

, + t3Context: T3Context, +) => Effect.Effect; + +declare const processT3Event:

( + event: OrchestrationEvent, +) => Effect.Effect; From 3050eaeb13824beb5157582a1d4a9b5848919f4d Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 7 Aug 2026 22:08:46 +0200 Subject: [PATCH 021/110] feat: add makeProcessor declaration --- apps/server/src/ntbs/processor.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index a41bcc6d3725..e35da4dcc6c8 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,6 +1,7 @@ import { type OrchestrationEvent, type ProjectId } from "@t3tools/contracts"; import type * as NTBS from "./schemas.ts"; import { Context, Data, Effect } from "effect"; +import type { NTBSAdapter } from "./adapter.ts"; /* NTBS architectural description: @@ -50,7 +51,7 @@ export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ export interface NTBSProcessor

{ readonly process: (event: ProcessorEvent

) => Effect.Effect; - readonly subscribeToT3Events: () => Effect.Effect; + readonly subscribeToT3Events: Effect.Effect; } export const makeNTBSProcessor =

(key: string) => @@ -64,3 +65,7 @@ declare const processAcceptedRequest:

( declare const processT3Event:

( event: OrchestrationEvent, ) => Effect.Effect; + +declare const makeProcessor:

( + adapter: NTBSAdapter

, +) => Effect.Effect, never, NTBSProcessorRequirements>; From 1cb5aaa63dc4a5162d5576f2f22f055ce10d156e Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 7 Aug 2026 23:23:10 +0200 Subject: [PATCH 022/110] feat: more ntbs processor work --- apps/server/src/ntbs/processor.ts | 37 +++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index e35da4dcc6c8..2bcf8676b832 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,7 +1,11 @@ -import { type OrchestrationEvent, type ProjectId } from "@t3tools/contracts"; +import { ThreadId, type OrchestrationEvent, type ProjectId } from "@t3tools/contracts"; import type * as NTBS from "./schemas.ts"; -import { Context, Data, Effect } from "effect"; +import { Context, Crypto, Data, Effect } from "effect"; import type { NTBSAdapter } from "./adapter.ts"; +import type { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import type { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import type { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; /* NTBS architectural description: @@ -57,6 +61,30 @@ export interface NTBSProcessor

{ export const makeNTBSProcessor =

(key: string) => Context.Service>(key); +type NTBSProcessorRequirements = + /* + Dispatches thread creation and turn-start commands. + Provides the T3 event stream used to detect outcomes. + */ + | OrchestrationEngineService + /* + Loads the selected T3 project and reads the completed thread + state and response tex. + */ + | ProjectionSnapshotQuery + /* + Creates the isolated branch and worktree for each accepted external request. + */ + | GitWorkflowService + /* + Runs the project setup scripts in the newly created worktree before agent work begins. + */ + | ProjectSetupScriptRunner + /* + Generates unique identifiers for the new thread, message, commands, and worktree branch. + */ + | Crypto.Crypto; + declare const processAcceptedRequest:

( request: NTBS.RequestAccepted

, t3Context: T3Context, @@ -66,6 +94,11 @@ declare const processT3Event:

( event: OrchestrationEvent, ) => Effect.Effect; +declare const startT3thread: ( + snapshot: string, + t3Context: T3Context, +) => Effect.Effect; + declare const makeProcessor:

( adapter: NTBSAdapter

, ) => Effect.Effect, never, NTBSProcessorRequirements>; From 693acf21881f3e56985b317eb8ef0472765ea0a4 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 8 Aug 2026 00:29:44 +0200 Subject: [PATCH 023/110] feat: document ntbs processor declarations --- apps/server/src/ntbs/processor.ts | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 2bcf8676b832..e7db068dda00 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -85,20 +85,66 @@ type NTBSProcessorRequirements = */ | Crypto.Crypto; +/** + * Creates a new T3 thread for an external request. + * + * Does nothing if the adapter has already handled the request. + * Otherwise starts the thread, records it, posts an acknowledgement, and records that message. + */ declare const processAcceptedRequest:

( request: NTBS.RequestAccepted

, t3Context: T3Context, ) => Effect.Effect; +/** + * Provider runtimes (like Claude Code) emit `turn.completed` but + * T3 consumes those internally and represents the result externally emitting only a `thread.session-set` event. + * This works for non-NTBS surfaces as they are notified to simply + * rerender the latest projection. + * + * But it does not work for NTBS ones that do not consume projections. + * + * Thus, we need to listen for re-emitted `thread.session-set` events and manually check the state of the thread. + * + * We read the project thread identified by the session event. + * - return `null` if the thread isn't done. + * - return final assistant text or plain error text otherwise. + * Reads the projected thread identified by the session event. + */ +declare const resolveT3Outcome: ( + event: Extract, +) => Effect.Effect< + { readonly threadId: ThreadId; readonly text: string } | null, + NTBSProcessorError +>; + +/** + * Handles T3 events that may indicate that a turn has ended. + * + * Ignores other events, threads with no adapter record, and responses that have been already posted. + * + * When a turn has ended, reads its result, posts it through the adapter and updates the lifecycle. + */ declare const processT3Event:

( event: OrchestrationEvent, ) => Effect.Effect; +/** + * Creates an isolated worktree and a new T3 thread from the source snapshot. + * + * Starts the first turn and returns the new thread ID. + * Does not read platform data or call the adapter. + */ declare const startT3thread: ( snapshot: string, t3Context: T3Context, ) => Effect.Effect; +/** + * Creates an NTBS processor for one adapter. + * + * Resolves the required T3 services and returns processor operations with no remaining requirements. + */ declare const makeProcessor:

( adapter: NTBSAdapter

, ) => Effect.Effect, never, NTBSProcessorRequirements>; From 1cc1ce3bc934d1222f09105437fbeb604f6e5562 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 8 Aug 2026 01:12:25 +0200 Subject: [PATCH 024/110] fix: ntbs processor and schemas flows --- apps/server/src/ntbs/processor.ts | 38 +++++++++++++++++++------------ apps/server/src/ntbs/schemas.ts | 2 +- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index e7db068dda00..cfd038fc699a 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -86,10 +86,31 @@ type NTBSProcessorRequirements = | Crypto.Crypto; /** - * Creates a new T3 thread for an external request. + * Creates an isolated worktree and a new T3 thread. * - * Does nothing if the adapter has already handled the request. - * Otherwise starts the thread, records it, posts an acknowledgement, and records that message. + * Does not start a turn, read platform data or call the adapter. + */ +declare const createT3Thread: ( + snapshot: string, + t3Context: T3Context, +) => Effect.Effect; + +/** + * Stars the first turn in an existing T3 thread. + */ +declare const startT3Turn: ( + threadId: ThreadId, + snapshot: string, +) => Effect.Effect; + +/** + * Handles an external request in this order: + * + * 1. Ask the adapter to accept it and stop if it is a duplicate. + * 2. Create the worktree and T3 thread. + * 3. Record `ThreadStarted` + * 4. Post and record the acknowledgement. + * 5. Start the first T3 turn with the source snapshot */ declare const processAcceptedRequest:

( request: NTBS.RequestAccepted

, @@ -129,17 +150,6 @@ declare const processT3Event:

( event: OrchestrationEvent, ) => Effect.Effect; -/** - * Creates an isolated worktree and a new T3 thread from the source snapshot. - * - * Starts the first turn and returns the new thread ID. - * Does not read platform data or call the adapter. - */ -declare const startT3thread: ( - snapshot: string, - t3Context: T3Context, -) => Effect.Effect; - /** * Creates an NTBS processor for one adapter. * diff --git a/apps/server/src/ntbs/schemas.ts b/apps/server/src/ntbs/schemas.ts index c409874d464c..0d0533fde033 100644 --- a/apps/server/src/ntbs/schemas.ts +++ b/apps/server/src/ntbs/schemas.ts @@ -36,7 +36,7 @@ export type RequestAccepted

= LifecycleEvent

& { }; export type ThreadStarted

= ThreadEvent

& { - /** T3 has created the new thread from the source snapshot */ + /** T3 has created the new thread. */ state: "thread.started"; }; From 2cf54d51a795bfd36d1678546bb7fca2873faf47 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 8 Aug 2026 01:54:57 +0200 Subject: [PATCH 025/110] fix: docs in ntbs processor --- apps/server/src/ntbs/processor.ts | 56 ++++++++++++++++++------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index cfd038fc699a..7b6381dd148e 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -12,9 +12,10 @@ import type { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunn 1. Generic NTBS processor: - Contains the shared workflow for every adapter. The business logic, regardless of the actual NTBS is identical - Makes queries to the specific platform adapter - - Uses private T3-specific effect to create a fresh thread and worktree, then starts the turn with `snapshot` + - Uses private T3-specific effect to create a fresh worktree and T3 thread - Saves `ThreadStarted` - - Posts to the NTBS platform through the adapter and saves `ThreadStartAcknowledgment` + - Posts the acknowledgment through the adapter and saves `ThreadStartedAcknowledgement` + - Starts the first turn with `snapshot` - Watches T3 events for completed work. - Finds the adapter record by T3 thread ID, posts the final result @@ -53,12 +54,21 @@ export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ }> {} export interface NTBSProcessor

{ + /** + * Routes adapter requests and T3 events through the shared NTBS workflow. + */ readonly process: (event: ProcessorEvent

) => Effect.Effect; + /** + * Consumes T3 events and passes them to `processT3Event`. + * + * Runs until interrupted by its caller. + * Logs individual processing failures and continues with later events. + */ readonly subscribeToT3Events: Effect.Effect; } -export const makeNTBSProcessor =

(key: string) => +export const makeNTBSProcessorTag =

(key: string) => Context.Service>(key); type NTBSProcessorRequirements = @@ -89,14 +99,13 @@ type NTBSProcessorRequirements = * Creates an isolated worktree and a new T3 thread. * * Does not start a turn, read platform data or call the adapter. + * + * The final title of the thread is generated by T3 after the first turn starts. */ -declare const createT3Thread: ( - snapshot: string, - t3Context: T3Context, -) => Effect.Effect; +declare const createT3Thread: (t3Context: T3Context) => Effect.Effect; /** - * Stars the first turn in an existing T3 thread. + * Starts the first turn in an existing T3 thread. */ declare const startT3Turn: ( threadId: ThreadId, @@ -118,19 +127,15 @@ declare const processAcceptedRequest:

( ) => Effect.Effect; /** - * Provider runtimes (like Claude Code) emit `turn.completed` but - * T3 consumes those internally and represents the result externally emitting only a `thread.session-set` event. - * This works for non-NTBS surfaces as they are notified to simply - * rerender the latest projection. - * - * But it does not work for NTBS ones that do not consume projections. + * Provider runtimes (like Claude Code) emit `turn.completed` events. + * T3 consumes those internally and exposes the resulting session change through a `thread.session-set` event. * - * Thus, we need to listen for re-emitted `thread.session-set` events and manually check the state of the thread. + * Native T3 clients can react by refreshing the thread projection. + * External NTBS adapters do not consume T3 projections automatically, so they must read the thread state themselves. * - * We read the project thread identified by the session event. - * - return `null` if the thread isn't done. - * - return final assistant text or plain error text otherwise. - * Reads the projected thread identified by the session event. + * This function reads the projected thread identified by the session event. + * It returns `null` if the latest turn has not ended. + * Otherwise it returns the final assistant text or plain text error. */ declare const resolveT3Outcome: ( event: Extract, @@ -142,11 +147,14 @@ declare const resolveT3Outcome: ( /** * Handles T3 events that may indicate that a turn has ended. * - * Ignores other events, threads with no adapter record, and responses that have been already posted. - * - * When a turn has ended, reads its result, posts it through the adapter and updates the lifecycle. + * 1. Ignore events other than `thread.session-set`. + * 2. Find the adapter record by thread ID. + * 3. Stop if no record exists or the response was already posted. + * 4. Resolve the T3 outcome and stop if the turn has not ended. + * 5. Record `ResponseAvailable`. + * 6. Post the response and record `ResponsePosted`. */ -declare const processT3Event:

( +declare const processT3Event: ( event: OrchestrationEvent, ) => Effect.Effect; @@ -155,6 +163,6 @@ declare const processT3Event:

( * * Resolves the required T3 services and returns processor operations with no remaining requirements. */ -declare const makeProcessor:

( +declare const makeNTBSProcessor:

( adapter: NTBSAdapter

, ) => Effect.Effect, never, NTBSProcessorRequirements>; From 0f2678f99fd8ab598d04fd2163617000d7a491b4 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 8 Aug 2026 02:30:00 +0200 Subject: [PATCH 026/110] chore: complete abstract/declaration phase --- apps/server/src/ntbs/adapter.ts | 44 ++++++++++++++++++++++-- apps/server/src/ntbs/platform-handler.ts | 26 ++++++++++++++ apps/server/src/ntbs/processor.ts | 2 +- apps/server/src/ntbs/schemas.ts | 4 ++- 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/ntbs/platform-handler.ts diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 393d43f15475..fdcbdf6ae2f6 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -1,3 +1,4 @@ +import type { ThreadId } from "@t3tools/contracts"; import * as NTBS from "./schemas.ts"; import { Context, Data, Effect } from "effect"; @@ -10,25 +11,64 @@ export class AdapterError extends Data.TaggedError("AdapterError")<{ readonly reason: string; }> {} +/** + * Defines the platform-specific operations used by the shared NTBS processor. + * + * The adapter detects duplicate requests, stores lifecycle data, finds that data + * from a T3 thread ID, and posts acknowledgements and responses. + * + * It does not create T3 threads or interpret T3 events. + */ export interface NTBSAdapter

{ + /** + * Stores the request before any T3 work begins. + * + * Returns `"duplicate"` if the same platform request was already stored. + * + * Returning `"accepted"` means this `RequestAccepted` state has been stored. + */ readonly accept: ( event: NTBS.RequestAccepted

, ) => Effect.Effect<"accepted" | "duplicate", AdapterError>; + /** + * Stores a lifecycle state. Does not perform any other business logic. + */ readonly save: (lifecycleEvent: NTBS.NTBSLifecycle

) => Effect.Effect; + /** + * Posts the working acknowledgement at the response destination, + * described by the event. + * + * Returns the platform's identifier for the posted message. + * + * The processor uses that identifier to save `ThreadStartedAcknowledgement`. + */ readonly postAcknowledgement: ( event: NTBS.ThreadStarted

, ) => Effect.Effect; + /** + * Posts the final T3 outcome at the response destination described + * by the event. + * + * Returns the platform's idenitifier for the posted message. + * The processor uses that identifier to save `ResponsePosted`. + */ readonly postResponse: ( event: NTBS.ResponseAvailable

, text: string, ) => Effect.Effect; + /** + * Finds the latest lifecycle state associated with a T3 thread. + * + * Fails with `ThreadNotFound` when this adapter has no request associated + * with that thread. + */ readonly findByThreadId: ( - threadId: string, + threadId: ThreadId, ) => Effect.Effect< Exclude, NTBS.RequestAccepted

>, ThreadNotFound | AdapterError >; } -export const makeNTBSAdapter =

(key: string) => +export const makeNTBSAdapterTag =

(key: string) => Context.Service>(key); diff --git a/apps/server/src/ntbs/platform-handler.ts b/apps/server/src/ntbs/platform-handler.ts new file mode 100644 index 000000000000..8206baf3108e --- /dev/null +++ b/apps/server/src/ntbs/platform-handler.ts @@ -0,0 +1,26 @@ +import { Context, Data, Effect } from "effect"; + +export class NTBSPlatformHandlerError extends Data.TaggedError("NTBSPlatformHandlerError")<{ + reason: string; +}> {} + +/** + * Connects a platform's incoming messages or comments to shared NTBS processor. + * + * It determines whether the input should start work. If so, it captures the platform data, + * source snapshot, and T3 context, then passes them to the processor. + * + * Duplicate detection, lifecycle storage, and platform API calls belong to the adapter. + */ +export interface NTBSPlatformHandler { + readonly handle: (input: Input) => Effect.Effect; +} + +/** + * Creates the Effect service tag used to provide and access one platform handler. + * + * This identifies the handler in the Effect context. + * It does not create the handler implementation. + */ +export const makeNTBSPlatformHandlerTag = (key: string) => + Context.Service>(key); diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 7b6381dd148e..d7e5e746e427 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -163,6 +163,6 @@ declare const processT3Event: ( * * Resolves the required T3 services and returns processor operations with no remaining requirements. */ -declare const makeNTBSProcessor:

( +export declare const makeNTBSProcessor:

( adapter: NTBSAdapter

, ) => Effect.Effect, never, NTBSProcessorRequirements>; diff --git a/apps/server/src/ntbs/schemas.ts b/apps/server/src/ntbs/schemas.ts index 0d0533fde033..3dd3fc4225e5 100644 --- a/apps/server/src/ntbs/schemas.ts +++ b/apps/server/src/ntbs/schemas.ts @@ -1,3 +1,5 @@ +import type { ThreadId } from "@t3tools/contracts"; + /** * Describes the platform-specific data of a * Non-Turn-Based-Surface. @@ -27,7 +29,7 @@ export type LifecycleEvent

= { export type ThreadEvent

= LifecycleEvent

& { t3Data: { /** The T3 thread created by the lifecycle event */ - threadId: string; + threadId: ThreadId; }; }; From 7cc33993961c781cdd93a4e9778f4e41529600ac Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 8 Aug 2026 15:44:57 +0200 Subject: [PATCH 027/110] chore: fixes part 1 --- docs/planning/fixes.md | 11 ++ docs/planning/ntbs-adversarial-review.md | 156 +++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 docs/planning/fixes.md create mode 100644 docs/planning/ntbs-adversarial-review.md diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md new file mode 100644 index 000000000000..7705f6a6ae6f --- /dev/null +++ b/docs/planning/fixes.md @@ -0,0 +1,11 @@ +# NTBS fixes + +This document collects the units of work identified by the adversarial review of the NTBS design. + +## 1. Recover accepted requests whose T3 thread was not recorded as started + +The adapter records `RequestAccepted` before the processor creates the T3 thread. If the server stops after accepting the request but before recording `ThreadStarted`, the request remains unfinished. A repeated delivery cannot safely solve this by starting fresh because it is treated as a duplicate, and the previous attempt may already have created a T3 thread. + +The request must retain a planned T3 thread ID before thread creation begins. Every creation attempt for that request must use the same thread ID, making a retry safe even if the previous attempt created the thread but failed before recording `ThreadStarted`. + +The adapter must expose accepted requests that have no recorded `ThreadStarted`. When the processor starts, it must find those requests and retry thread creation using their stored thread IDs. A duplicate delivery must not create another thread; it may resume the existing unfinished request. diff --git a/docs/planning/ntbs-adversarial-review.md b/docs/planning/ntbs-adversarial-review.md new file mode 100644 index 000000000000..66e2356ead25 --- /dev/null +++ b/docs/planning/ntbs-adversarial-review.md @@ -0,0 +1,156 @@ +# NTBS skeleton adversarial review + +**Status:** review of the declaration-phase skeleton in `apps/server/src/ntbs` + +**Scope:** `schemas.ts`, `processor.ts`, `platform-handler.ts`, `adapter.ts`, reviewed against [ntbs.md](./ntbs.md), [ntbs-architecture.md](./ntbs-architecture.md), [ntbs-event-processing.md](./ntbs-event-processing.md), [ntbs-plan.md](./ntbs-plan.md), and the existing implementation (`apps/server/src/jira`, `apps/server/src/github`, `apps/server/src/ws.ts`, `apps/server/src/orchestration`, `packages/contracts`). + +## Verdict + +The core seam is right — a shared lifecycle processor with per-platform adapters is exactly what `JiraIssueBridge` and `GitHubPrBridge` already share informally (they import each other's outcome-resolution helpers), and thread-per-event kills the hairiest logic in the current bridges (thread reuse, turn targeting against a shared thread). But the skeleton as declared has **two liveness holes that make it unimplementable as specified**, quietly **regresses five capabilities the current bridges already have**, **re-declares a mechanic the codebase already ships** (turn-start bootstrap), and carries at least three abstractions that can be deleted. + +Findings are ordered by severity within each section and numbered globally for reference. + +## A. Contract holes — these produce stuck/wrong external state if implemented as declared + +### 1. A crash after `accept` loses the event forever, by construction + +`adapter.accept` persists `RequestAccepted` _before any T3 work_ and returns `"duplicate"` on redelivery (`adapter.ts:22-33`). But: + +- `findByThreadId` structurally excludes `RequestAccepted` (`adapter.ts:65-70`) — no thread exists yet, so the record is unreachable; +- the processor interface has only `process` and `subscribeToT3Events` (`processor.ts:56-69`) — no recovery entry point; +- Jira/GitHub webhooks cannot save you, because `jira/http.ts` responds 202 before processing and fork-detaches, so platforms do not redeliver. + +Crash between `accept` and thread creation → idempotency key consumed, event never processed, no path ever revisits it. The current Jira bridge solves exactly this with a startup `restore` sweep over `status: "processing"` deliveries (`JiraIssueBridge.ts:867-875`). The plan doc's own step 7 ("persist every lifecycle transition so interrupted processing can resume") is unsatisfiable with this interface. + +**Fix:** add `listIncomplete` (or similar) to the adapter contract plus a processor startup-recovery pass, and consider `accept` returning the existing lifecycle state instead of a bare `"duplicate"` so redeliveries can resume half-done work. + +### 2. The state machine has a typed dead-end when acknowledgement posting fails + +`ResponseAvailable` and `ResponsePosted` both _require_ `acknowledgementMessageId` (`schemas.ts:51-62`). If `postAcknowledgement` fails permanently — or the process dies between saving `ThreadStarted` and posting the ack — the turn still runs and completes, the outcome event arrives, `findByThreadId` returns `ThreadStarted`… and step 5 of `processT3Event` ("Record `ResponseAvailable`", `processor.ts:150-159`) is unconstructible. The answer exists and can never be posted. + +The architecture doc defers "error and retry lifecycle states" to a TODO, but this is not an error state — it is the happy path after one failed platform call. + +**Fix:** either make `acknowledgementMessageId` optional in the response states, or model ack-retry explicitly. Note the constraint is real for Discord (the outcome must reply to the ack, per ntbs-event-processing.md §Discord), so "post outcome without ack" needs a per-platform answer, which argues for the adapter receiving the whole record and deciding. + +### 3. Outcome detection is anchored on "latest turn", which is wrong the moment anyone touches the thread + +`t3Data` keeps only `threadId` (`schemas.ts:29-34`) and `resolveT3Outcome` reads "the latest turn" from the projection (`processor.ts:140-145`). NTBS threads are ordinary threads — visible in native clients, no lock. If a human opens one and sends a follow-up (or a queued message drains), `latestTurn` now describes _their_ turn: the processor will either post nothing or post the second turn's answer to the Jira comment. + +This regresses against both the NTBS docs (ntbs-architecture.md's record keeps `threadId`/`userMessageId`/`turnId`; ntbs-plan.md explicitly says "the lifecycle types must represent that sequence accurately") and the current implementation (`JiraDeliveryStore` keeps `userMessageId`, `previousTurnId`, `targetTurnId` and the bridges do targeted turn discovery). + +`userMessageId` is free — the processor generates it at dispatch. `turnId` genuinely arrives later (the provider adapter mints it; the decider emits `thread.turn-start-requested` with `turnId: null`). + +**Fix:** store `userMessageId` at `ThreadStarted`, resolve the outcome for the turn that answered _that message_, and record the discovered `turnId` when it appears. + +### 4. No timeout anywhere — a silently hung provider leaves an ack dangling forever + +Orchestration has no turn timeout (only 5s/45s provider-_control_ timeouts in `ProviderCommandReactor`), and if an ACP connection drops silently there is no `thread.session-set` until a server restart triggers `OrphanSessionRecovery`. The current Jira bridge covers this with a configurable 30-minute poll deadline plus a "still working" fallback message (`JiraIssueBridge.ts:504-559`). The skeleton has no deadline concept, yet ntbs.md promises "failure, timeout, or cancellation returns a response that explicitly reports the outcome." + +**Fix:** the processor (platform-independent) owns a per-record deadline; on expiry it posts a timeout outcome and marks the record posted — and document that a late real answer is then dropped, or define a supersede rule. + +### 5. The event subscription cannot survive a restart, and nothing compensates + +`subscribeToT3Events` necessarily rides `OrchestrationEngine.streamDomainEvents`, which is an in-memory PubSub — "hot runtime stream (new events only)" (`orchestration/Services/OrchestrationEngine.ts:52-57`). Any turn that completes while the server (or just the processor fiber) is down emits into the void. `readEvents(fromSequenceExclusive)` exists, but the skeleton stores no cursor anywhere, and the engine's own docstring warns against stale-cursor replay. + +**No cursor is needed:** since `resolveT3Outcome` already treats the projection as the source of truth, the startup-recovery pass from finding 1 — re-check the projection for every incomplete record — also closes this hole. Treat the live stream purely as a wake-up signal. + +This is also the honest framing of a half-made design choice: the current bridges _poll_ per-delivery, which gets recovery and timeouts almost for free at the cost of 1s ticks. Event-wakeup + projection-truth + startup sweep is a fine steady state, but say so explicitly — the mechanism is currently smeared across `subscribeToT3Events`' docstring. + +### 6. Double-posting is possible and undocumented + +Crash between a successful `postResponse` and `save(ResponsePosted)` → recovery re-posts. Platforms have no idempotent comment-create, so this is inherent at-least-once delivery. The current bridge has the same window; that is acceptable — but the adapter contract should state which semantics adapters must expect, because it changes what `save` failures mean. + +## B. The codebase already has things the skeleton re-declares or ignores + +### 7. The turn-start bootstrap already exists as a single command — do not rebuild it from Git primitives + +`ThreadTurnStartCommand.bootstrap` covers createThread + prepareWorktree + runSetupScript + first message + turn start (`packages/contracts/src/orchestration.ts:744-803`). Its server-side executor is currently inlined in `ws.ts:816-1168` (`dispatchBootstrapTurnStart`), including subtleties the skeleton would otherwise re-learn the hard way: `thread.meta.update` after worktree creation, polling the projection until the worktree is _visible_ before provider start (`ws.ts:953`), setup-script activity records, `startFromOrigin` fetch/resolve. + +The skeleton instead declares its own `createT3Thread`/`startT3Turn` split depending directly on `GitWorkflowService` + `ProjectSetupScriptRunner` + `Crypto` (`processor.ts:74-113`) — i.e., a third copy of the mechanic beside `ws.ts` and `JiraIssueBridge.createThreadForIssue` (which the plan itself calls "a reference, not the desired architecture"). The plan's step 2 — extract the ws.ts mechanic into a service both native clients and NTBS call — is the right move and the skeleton silently dropped it. + +**Fix:** do the extraction; the processor's dependency list shrinks to OrchestrationEngine + ProjectionSnapshotQuery + the extracted service. One nuance to encode there: ws.ts _continues_ on worktree-prep failure (`ws.ts:879`), but NTBS must _fail_ the event instead — isolation is an agreed hard requirement (ntbs.md §concurrency). The shared service needs a strictness knob. + +### 8. Provenance is first-class in T3 and the skeleton cannot carry it + +`SourceChannel` already enumerates `discord`/`github`/`jira`/`slack`/`teams` (`packages/contracts/src/identity.ts:60-73`), `SourceRef`/`SourceLocation` carry issueKey/owner/repo/number/guildId (`identity.ts:75-116`), `ThreadTurnStartCommand` has `source`/`sourceHint` seats (`orchestration.ts:796-801`), threads have `originSource` (`orchestration.ts:443`), and the current bridges stamp it via `buildIntegrationSourceRef` (`identity/stampSource.ts:175`). + +`T3Context = { projectId, revision }` (`processor.ts:36-39`) has no seat for any of this, so NTBS-created threads would lose origin badges, participant attribution, and identity-map resolution that native clients already render — a visible regression vs. today's Jira bridge. It also falsifies the architecture doc's "T3 does not receive or interpret platform data" absolutism: T3 already _stores and renders_ platform provenance; what it does not do is interpret it for routing. + +**Fix:** add `source`/`sourceHint` to `T3Context` (or the processor's dispatch), and reword the doc's opacity principle to "T3 never interprets platform data for lifecycle/routing decisions." + +### 9. `T3Context` is missing everything else a thread needs, with no stated defaulting policy + +Thread creation requires `title`, `modelSelection`, `runtimeMode`, `interactionMode` (`orchestration.ts:627-641`). The current bridge resolves model as `project.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection()`, title from the issue summary, runtimeMode `"full-access"` (`JiraIssueBridge.ts:396-399`). Either `T3Context` grows optional overrides (platforms will eventually want them — e.g. a Jira label selecting a model) or the processor owns the defaulting policy; right now neither is written down. Same for `revision` — is it a branch name or pinned SHA, resolved at accept-time or worktree-time? The current bridge resolves `origin/` at worktree time. + +### 10. `snapshot: string` will hit the 120k input cap and silently forecloses attachments + +`PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000` (`orchestration.ts:145`) — a captured Jira issue + comment history or a PR diff snapshot can exceed it, and then the dispatch fails and the lifecycle wedges (see finding 1). The processor must own a truncation policy. + +Separately, turn messages support image attachments (`ChatAttachment`), and Jira/GitHub/Discord invocations routinely include screenshots; `snapshot: string` forecloses them. Punting is fine — write the exclusion into the lifecycle module so it is a decision, not an accident. + +### 11. Actor trust has no home + +`classifyJiraActorTrust`/`githubActorTrust` gate who may trigger work, with a "context-only" fallback that _posts a note instead of starting work_. The platform-handler docstring ("determines whether the input should start work", `platform-handler.ts:8-14`) implicitly absorbs trust but never names it, and the context-only path — an outbound platform post with no lifecycle — fits neither handler nor adapter contract as written. It is fine to keep it platform-side and outside the lifecycle; say so explicitly, because it is a security boundary. + +## C. Simplifications — things to delete or merge + +### 12. `platform-handler.ts` is a vacuous abstraction — delete it + +`NTBSPlatformHandler` is `handle: Input => Effect` (`platform-handler.ts:15-17`). Nothing consumes it polymorphically and nothing ever will — each platform's HTTP route calls its own handler with its own `Input` type; a generic interface over an existential `Input` has no call sites by construction. It is a function type with a ceremony tag factory. The real architecture is two-piece (processor + adapter); the "handler" is just each adapter package's inbound edge, and a comment in `processor.ts` already documents that convention adequately. + +### 13. Collapse `ProcessorEvent` — the union wraps two statically-known callers + +`{ source: "adapter" } | { source: "t3" }` (`processor.ts:41-50`) has exactly one producer per arm, and the `"t3"` arm's only legitimate producer is the processor's _own_ subscription. Exposing it invites outsiders to inject synthetic T3 events, and `"adapter"` is a misnomer anyway (the platform handler builds it, not the adapter). Replace with `handleRequest(request, t3Context)` plus the internal subscription; tests can fake the engine's stream through the service dependency instead of injecting events through the front door. + +Also reconsider exposing `subscribeToT3Events` at all — if callers must wire it, name it for what it is (`run`/`daemon`); its current docstring leaks a private function name (`processT3Event`). + +### 14. `RequestAccepted` lies about its own state + +The handler constructs `state: "request.accepted"` _before_ the adapter has accepted anything, then `processAcceptedRequest` step 1 "asks the adapter to accept it" (`processor.ts:124-127`) — a value asserting a persisted state that does not exist yet, named "accepted" while acceptance is pending. Pass the base `{ platformData, snapshot }` into `accept` and let the adapter mint the accepted state. This fixes the semantics and removes a footgun for adapter authors. + +### 15. Justify each of the five states with a distinct recovery action, or cut to three + +`ResponseAvailable` as a _persisted_ state buys nothing today: recovery must re-derive the outcome from the projection anyway (finding 5), and dedup only needs "posted". The current stores run on effectively three states (`received`/`processing`/`completed`) plus nullable message IDs. Keep five states only if each maps to a distinct crash-recovery behavior — writing that table down (state → recovery action) is the cheapest way to force findings 1/2/5 to resolution. If a state has no recovery action of its own, it is a log line, not a state. + +### 16. Drop the tag factories until something resolves them from context + +Adapter → processor → handler is a straight constructor chain assembled once at bootstrap; only the HTTP route plausibly needs a context tag. Three string-keyed generic tag factories (`makeNTBS*Tag(key)`) also deviate from the codebase convention (`class X extends Context.Service()("t3/…")` with namespaced IDs) and nothing type-checks that two call sites will not collide on a bare key like `"jira"`. If kept, namespace the keys. (`Context.Service(key)` is a legitimate effect-smol form, so the factories are _sound_, just probably unnecessary.) + +### 17. Design the error taxonomy around retryability, not strings + +`AdapterError { reason: string }` / `NTBSProcessorError { reason: string }` erase the one distinction the whole outbox pattern turns on: transient (429, network) vs. permanent (403, deleted comment). The current `JiraAppClient` already encodes retry-vs-fallback decisions (400/404 → try next parent). "Will be refined later" is noted in `adapter.ts` — but retryability is the _first_ refinement, and it changes method signatures, so decide it before adapters exist. + +### 18. Do not collapse the outcome to `text` before the adapter sees it + +`resolveT3Outcome` returns bare text and `postResponse(event, text)` posts it (`processor.ts:140-145`, `adapter.ts:55-58`). The docs distinguish answer/failure/timeout/cancellation and adapters own platform rendering — yet the one place rendering matters (a failure vs. an answer) arrives pre-flattened, while ack copy is fully adapter-owned. Asymmetric. Pass a small tagged outcome (`{ kind: "answer" | "failure" | "interrupted" | "timeout"; text }`) and let the adapter format. + +### 19. Naming/file nits, worth fixing while it is cheap + +- `schemas.ts` contains no `effect/Schema` schemas — misleading in a repo where "schema" means that specifically (`packages/contracts` is "schema-only"); call it `lifecycle.ts`. +- Three different things are called "event" in one file (platform events, T3 events, and these persisted _states_ with event-shaped names like `"thread.started"`); the base type `LifecycleEvent` is a state/record, and its docstring still says "NTBSEvent" (`schemas.ts:18`) while the docs say `NtbsEvent` and the state names diverge from the doc's (`acknowledgementPosted` vs `thread.started.acknowledged`). +- Typos: "response tex" (`processor.ts:83`), "idenitifier" (`adapter.ts:52`). +- Casing: `NTBSAdapter` vs the docs' `Ntbs`. + +## D. Decisions to make now (cheap in planning, expensive later) + +- **Post-response thread policy.** Thread-per-event with no terminal action means worktrees and inbox noise accumulate unboundedly — `WorktreeLifecycle` only cleans on archive, and nobody archives NTBS threads. The docs acknowledge the noise but propose nothing. Decide: auto-settle (or archive) after `ResponsePosted`, keeping worktree-retention rules in one place. +- **Concurrency caps.** A chatty Jira issue or Discord thread can fork-bomb worktrees + provider sessions. The cap/queue/reject policy is platform-independent and belongs in the processor; the current bridge only bounds _recovery_ concurrency (4). +- **In-process vs. remote adapters.** The skeleton is in-process Effect services; Jira/GitHub webhooks fit, but today's Discord integration is an external bot speaking WS with `sourceHint` (`identity/stampSource.ts:2-4`). ntbs.md's own framing ("adapters should be able to obtain an initial state and then receive subsequent changes") describes a _protocol_, not an in-process interface. Building in-process first is fine — but state that the Discord port means either moving the bot in-server or exposing the processor over a transport, so nobody bakes in-process assumptions into the lifecycle store. +- **Ack-before-turn ordering.** The skeleton posts the ack before starting the turn (`processor.ts:117-123`); the plan doc ordered turn-start first. Ack-first is currently _forced_ by finding 2's type constraint and costs a platform round-trip of agent latency on every event. If `acknowledgementMessageId` becomes optional in response states, the ordering becomes free — choose it deliberately rather than inheriting it from the type shape. +- **Snapshot retention.** Adapters persist external user content (snapshots) indefinitely; platforms let users delete messages. The docs punt to adapters — fine, but record it as a known compliance question, and note the current store's ~2000-record cap as prior art. + +## What is right (keep it) + +- Thread-per-event genuinely deletes the worst code in the current bridges (`resolveLinkedThreadId`, ambiguous-link handling, target-turn discovery against shared threads). +- The adapter surface (`accept`/`save`/`find`/`post*`) is small, in-memory-fakeable, and matches the plan's testing strategy. +- Keeping platform data opaque-generic (`PlatformData`) while the processor owns sequencing is the correct division — every platform-independent behavior identified in the Jira bridge analysis fits it once findings 1–5 are fixed. + +## Summary + +The skeleton is a good shape wrapped around an incomplete failure model: + +1. Fix the recovery story (findings 1, 5), the ack dead-end (2), turn anchoring (3), and timeouts (4) in the contract now. +2. Reuse the bootstrap command and provenance plumbing instead of re-declaring them (7, 8). +3. Delete the platform-handler layer (12). + +Update [ntbs-architecture.md](./ntbs-architecture.md) alongside — several findings (3, 8) are places where the skeleton diverged from decisions the docs already got right. From 06d98fdf6aff6de83e75e82124203ebf60a39d05 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 8 Aug 2026 17:07:36 +0200 Subject: [PATCH 028/110] feat: review removal of the requestaccepted lifecycle event --- docs/planning/fixes.md | 16 +++++-- docs/planning/ideas.md | 21 +++++++++ docs/planning/ntbs-adversarial-review.md | 56 +++++++----------------- 3 files changed, 49 insertions(+), 44 deletions(-) create mode 100644 docs/planning/ideas.md diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md index 7705f6a6ae6f..2e70003758d1 100644 --- a/docs/planning/fixes.md +++ b/docs/planning/fixes.md @@ -2,10 +2,18 @@ This document collects the units of work identified by the adversarial review of the NTBS design. -## 1. Recover accepted requests whose T3 thread was not recorded as started +## 1. Remove the pre-thread lifecycle state -The adapter records `RequestAccepted` before the processor creates the T3 thread. If the server stops after accepting the request but before recording `ThreadStarted`, the request remains unfinished. A repeated delivery cannot safely solve this by starting fresh because it is treated as a duplicate, and the previous attempt may already have created a T3 thread. +`RequestAccepted` exists to recover a request when the server stops before recording `ThreadStarted`. Supporting that narrow failure window requires planned thread IDs, searches for unfinished requests, startup retries, and rules for resuming duplicates. -The request must retain a planned T3 thread ID before thread creation begins. Every creation attempt for that request must use the same thread ID, making a retry safe even if the previous attempt created the thread but failed before recording `ThreadStarted`. +Do not add that machinery in the first implementation. Remove `RequestAccepted` and make `ThreadStarted` the first stored lifecycle state. Record it as soon as the basic T3 thread exists, before slower worktree preparation or project setup begins. -The adapter must expose accepted requests that have no recorded `ThreadStarted`. When the processor starts, it must find those requests and retry thread creation using their stored thread IDs. A duplicate delivery must not create another thread; it may resume the existing unfinished request. +This deliberately accepts one limitation: if the server stops before `ThreadStarted` is saved, the request may be lost. The user receives no acknowledgement and can send the request again. If this becomes a real problem, each adapter can later inspect recent platform messages and recover missing requests using the capabilities of that platform. + +## 2. Make acknowledgements independent from the shared lifecycle + +The acknowledgement is a platform message such as "working on it." It improves feedback for the user, but the current types make its message ID mandatory for `ResponseAvailable` and `ResponsePosted`. If posting the acknowledgement fails, the processor cannot represent or post the final response even though T3 work can continue. + +After creating the T3 thread, the processor records `ThreadStarted`. Starting the T3 work and attempting to post the acknowledgement are then independent operations. A failed acknowledgement must not prevent the work from starting, completing, or returning its final response. + +Remove `ThreadStartedAcknowledgement` from the shared lifecycle and remove `acknowledgementMessageId` from later lifecycle states. An adapter may retain the acknowledgement ID in its own storage and retry posting when appropriate, but final-response processing must not depend on it. diff --git a/docs/planning/ideas.md b/docs/planning/ideas.md new file mode 100644 index 000000000000..4edd50d3f449 --- /dev/null +++ b/docs/planning/ideas.md @@ -0,0 +1,21 @@ +# NTBS ideas + +## Keep the shared lifecycle small + +There is a tradeoff between recovering every possible interruption and keeping the first implementation simple. A saved `RequestAccepted` state could recover the rare case where the server receives a request but stops before creating its T3 thread. Doing that safely would also require planned thread IDs, startup searches, retries, and duplicate handling. + +For now, the shared lifecycle should begin with `ThreadStarted`. The processor should save it as soon as the basic T3 thread exists, before slower preparation begins. A request can be lost if the server stops before that point, but the missing acknowledgement makes the failure visible and the user can send the request again. + +A stronger recovery system can be added later if real usage requires it. Each adapter could inspect recent messages on its platform, find requests that have no corresponding T3 thread, and submit them again. This belongs to the adapter because Jira, GitHub, Discord, and Teams provide different ways to read their recent messages. + +## Remove acknowledgement from the shared lifecycle + +The acknowledgement is platform feedback, such as a "working on it" message. It should not be a required stage in the shared NTBS lifecycle because failing to post it, or failing to save its message ID, must not prevent the processor from representing and posting the final response. + +Remove `ThreadStartedAcknowledgement` from the lifecycle and remove `acknowledgementMessageId` from `ResponseAvailable` and `ResponsePosted`. The adapter may still post an acknowledgement and retain its identifier in its own platform-specific storage when needed. When posting the final response, the adapter can reply to the acknowledgement or fall back to the original source message according to the platform's capabilities. + +The shared sequence becomes: + +`Create the T3 thread → record ThreadStarted → start the work and attempt the acknowledgement independently` + +The processor does not use acknowledgement success as a condition for continuing. Posting may happen alongside the start of T3 work, and an adapter may retry a failed acknowledgement, but the final answer always remains tied to the original response destination. If a platform benefits from replying to the acknowledgement, its adapter can use the identifier stored in its own data without adding that dependency to the shared lifecycle. diff --git a/docs/planning/ntbs-adversarial-review.md b/docs/planning/ntbs-adversarial-review.md index 66e2356ead25..78e6f5127d51 100644 --- a/docs/planning/ntbs-adversarial-review.md +++ b/docs/planning/ntbs-adversarial-review.md @@ -12,27 +12,7 @@ Findings are ordered by severity within each section and numbered globally for r ## A. Contract holes — these produce stuck/wrong external state if implemented as declared -### 1. A crash after `accept` loses the event forever, by construction - -`adapter.accept` persists `RequestAccepted` _before any T3 work_ and returns `"duplicate"` on redelivery (`adapter.ts:22-33`). But: - -- `findByThreadId` structurally excludes `RequestAccepted` (`adapter.ts:65-70`) — no thread exists yet, so the record is unreachable; -- the processor interface has only `process` and `subscribeToT3Events` (`processor.ts:56-69`) — no recovery entry point; -- Jira/GitHub webhooks cannot save you, because `jira/http.ts` responds 202 before processing and fork-detaches, so platforms do not redeliver. - -Crash between `accept` and thread creation → idempotency key consumed, event never processed, no path ever revisits it. The current Jira bridge solves exactly this with a startup `restore` sweep over `status: "processing"` deliveries (`JiraIssueBridge.ts:867-875`). The plan doc's own step 7 ("persist every lifecycle transition so interrupted processing can resume") is unsatisfiable with this interface. - -**Fix:** add `listIncomplete` (or similar) to the adapter contract plus a processor startup-recovery pass, and consider `accept` returning the existing lifecycle state instead of a bare `"duplicate"` so redeliveries can resume half-done work. - -### 2. The state machine has a typed dead-end when acknowledgement posting fails - -`ResponseAvailable` and `ResponsePosted` both _require_ `acknowledgementMessageId` (`schemas.ts:51-62`). If `postAcknowledgement` fails permanently — or the process dies between saving `ThreadStarted` and posting the ack — the turn still runs and completes, the outcome event arrives, `findByThreadId` returns `ThreadStarted`… and step 5 of `processT3Event` ("Record `ResponseAvailable`", `processor.ts:150-159`) is unconstructible. The answer exists and can never be posted. - -The architecture doc defers "error and retry lifecycle states" to a TODO, but this is not an error state — it is the happy path after one failed platform call. - -**Fix:** either make `acknowledgementMessageId` optional in the response states, or model ack-retry explicitly. Note the constraint is real for Discord (the outcome must reply to the ack, per ntbs-event-processing.md §Discord), so "post outcome without ack" needs a per-platform answer, which argues for the adapter receiving the whole record and deciding. - -### 3. Outcome detection is anchored on "latest turn", which is wrong the moment anyone touches the thread +### 1. Outcome detection is anchored on "latest turn", which is wrong the moment anyone touches the thread `t3Data` keeps only `threadId` (`schemas.ts:29-34`) and `resolveT3Outcome` reads "the latest turn" from the projection (`processor.ts:140-145`). NTBS threads are ordinary threads — visible in native clients, no lock. If a human opens one and sends a follow-up (or a queued message drains), `latestTurn` now describes _their_ turn: the processor will either post nothing or post the second turn's answer to the Jira comment. @@ -42,13 +22,13 @@ This regresses against both the NTBS docs (ntbs-architecture.md's record keeps ` **Fix:** store `userMessageId` at `ThreadStarted`, resolve the outcome for the turn that answered _that message_, and record the discovered `turnId` when it appears. -### 4. No timeout anywhere — a silently hung provider leaves an ack dangling forever +### 2. No timeout anywhere — a silently hung provider leaves an ack dangling forever Orchestration has no turn timeout (only 5s/45s provider-_control_ timeouts in `ProviderCommandReactor`), and if an ACP connection drops silently there is no `thread.session-set` until a server restart triggers `OrphanSessionRecovery`. The current Jira bridge covers this with a configurable 30-minute poll deadline plus a "still working" fallback message (`JiraIssueBridge.ts:504-559`). The skeleton has no deadline concept, yet ntbs.md promises "failure, timeout, or cancellation returns a response that explicitly reports the outcome." **Fix:** the processor (platform-independent) owns a per-record deadline; on expiry it posts a timeout outcome and marks the record posted — and document that a late real answer is then dropped, or define a supersede rule. -### 5. The event subscription cannot survive a restart, and nothing compensates +### 3. The event subscription cannot survive a restart, and nothing compensates `subscribeToT3Events` necessarily rides `OrchestrationEngine.streamDomainEvents`, which is an in-memory PubSub — "hot runtime stream (new events only)" (`orchestration/Services/OrchestrationEngine.ts:52-57`). Any turn that completes while the server (or just the processor fiber) is down emits into the void. `readEvents(fromSequenceExclusive)` exists, but the skeleton stores no cursor anywhere, and the engine's own docstring warns against stale-cursor replay. @@ -56,13 +36,13 @@ Orchestration has no turn timeout (only 5s/45s provider-_control_ timeouts in `P This is also the honest framing of a half-made design choice: the current bridges _poll_ per-delivery, which gets recovery and timeouts almost for free at the cost of 1s ticks. Event-wakeup + projection-truth + startup sweep is a fine steady state, but say so explicitly — the mechanism is currently smeared across `subscribeToT3Events`' docstring. -### 6. Double-posting is possible and undocumented +### 4. Double-posting is possible and undocumented Crash between a successful `postResponse` and `save(ResponsePosted)` → recovery re-posts. Platforms have no idempotent comment-create, so this is inherent at-least-once delivery. The current bridge has the same window; that is acceptable — but the adapter contract should state which semantics adapters must expect, because it changes what `save` failures mean. ## B. The codebase already has things the skeleton re-declares or ignores -### 7. The turn-start bootstrap already exists as a single command — do not rebuild it from Git primitives +### 5. The turn-start bootstrap already exists as a single command — do not rebuild it from Git primitives `ThreadTurnStartCommand.bootstrap` covers createThread + prepareWorktree + runSetupScript + first message + turn start (`packages/contracts/src/orchestration.ts:744-803`). Its server-side executor is currently inlined in `ws.ts:816-1168` (`dispatchBootstrapTurnStart`), including subtleties the skeleton would otherwise re-learn the hard way: `thread.meta.update` after worktree creation, polling the projection until the worktree is _visible_ before provider start (`ws.ts:953`), setup-script activity records, `startFromOrigin` fetch/resolve. @@ -70,7 +50,7 @@ The skeleton instead declares its own `createT3Thread`/`startT3Turn` split depen **Fix:** do the extraction; the processor's dependency list shrinks to OrchestrationEngine + ProjectionSnapshotQuery + the extracted service. One nuance to encode there: ws.ts _continues_ on worktree-prep failure (`ws.ts:879`), but NTBS must _fail_ the event instead — isolation is an agreed hard requirement (ntbs.md §concurrency). The shared service needs a strictness knob. -### 8. Provenance is first-class in T3 and the skeleton cannot carry it +### 6. Provenance is first-class in T3 and the skeleton cannot carry it `SourceChannel` already enumerates `discord`/`github`/`jira`/`slack`/`teams` (`packages/contracts/src/identity.ts:60-73`), `SourceRef`/`SourceLocation` carry issueKey/owner/repo/number/guildId (`identity.ts:75-116`), `ThreadTurnStartCommand` has `source`/`sourceHint` seats (`orchestration.ts:796-801`), threads have `originSource` (`orchestration.ts:443`), and the current bridges stamp it via `buildIntegrationSourceRef` (`identity/stampSource.ts:175`). @@ -78,53 +58,49 @@ The skeleton instead declares its own `createT3Thread`/`startT3Turn` split depen **Fix:** add `source`/`sourceHint` to `T3Context` (or the processor's dispatch), and reword the doc's opacity principle to "T3 never interprets platform data for lifecycle/routing decisions." -### 9. `T3Context` is missing everything else a thread needs, with no stated defaulting policy +### 7. `T3Context` is missing everything else a thread needs, with no stated defaulting policy Thread creation requires `title`, `modelSelection`, `runtimeMode`, `interactionMode` (`orchestration.ts:627-641`). The current bridge resolves model as `project.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection()`, title from the issue summary, runtimeMode `"full-access"` (`JiraIssueBridge.ts:396-399`). Either `T3Context` grows optional overrides (platforms will eventually want them — e.g. a Jira label selecting a model) or the processor owns the defaulting policy; right now neither is written down. Same for `revision` — is it a branch name or pinned SHA, resolved at accept-time or worktree-time? The current bridge resolves `origin/` at worktree time. -### 10. `snapshot: string` will hit the 120k input cap and silently forecloses attachments +### 8. `snapshot: string` will hit the 120k input cap and silently forecloses attachments `PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000` (`orchestration.ts:145`) — a captured Jira issue + comment history or a PR diff snapshot can exceed it, and then the dispatch fails and the lifecycle wedges (see finding 1). The processor must own a truncation policy. Separately, turn messages support image attachments (`ChatAttachment`), and Jira/GitHub/Discord invocations routinely include screenshots; `snapshot: string` forecloses them. Punting is fine — write the exclusion into the lifecycle module so it is a decision, not an accident. -### 11. Actor trust has no home +### 9. Actor trust has no home `classifyJiraActorTrust`/`githubActorTrust` gate who may trigger work, with a "context-only" fallback that _posts a note instead of starting work_. The platform-handler docstring ("determines whether the input should start work", `platform-handler.ts:8-14`) implicitly absorbs trust but never names it, and the context-only path — an outbound platform post with no lifecycle — fits neither handler nor adapter contract as written. It is fine to keep it platform-side and outside the lifecycle; say so explicitly, because it is a security boundary. ## C. Simplifications — things to delete or merge -### 12. `platform-handler.ts` is a vacuous abstraction — delete it +### 10. `platform-handler.ts` is a vacuous abstraction — delete it `NTBSPlatformHandler` is `handle: Input => Effect` (`platform-handler.ts:15-17`). Nothing consumes it polymorphically and nothing ever will — each platform's HTTP route calls its own handler with its own `Input` type; a generic interface over an existential `Input` has no call sites by construction. It is a function type with a ceremony tag factory. The real architecture is two-piece (processor + adapter); the "handler" is just each adapter package's inbound edge, and a comment in `processor.ts` already documents that convention adequately. -### 13. Collapse `ProcessorEvent` — the union wraps two statically-known callers +### 11. Collapse `ProcessorEvent` — the union wraps two statically-known callers `{ source: "adapter" } | { source: "t3" }` (`processor.ts:41-50`) has exactly one producer per arm, and the `"t3"` arm's only legitimate producer is the processor's _own_ subscription. Exposing it invites outsiders to inject synthetic T3 events, and `"adapter"` is a misnomer anyway (the platform handler builds it, not the adapter). Replace with `handleRequest(request, t3Context)` plus the internal subscription; tests can fake the engine's stream through the service dependency instead of injecting events through the front door. Also reconsider exposing `subscribeToT3Events` at all — if callers must wire it, name it for what it is (`run`/`daemon`); its current docstring leaks a private function name (`processT3Event`). -### 14. `RequestAccepted` lies about its own state - -The handler constructs `state: "request.accepted"` _before_ the adapter has accepted anything, then `processAcceptedRequest` step 1 "asks the adapter to accept it" (`processor.ts:124-127`) — a value asserting a persisted state that does not exist yet, named "accepted" while acceptance is pending. Pass the base `{ platformData, snapshot }` into `accept` and let the adapter mint the accepted state. This fixes the semantics and removes a footgun for adapter authors. - -### 15. Justify each of the five states with a distinct recovery action, or cut to three +### 12. Justify each of the five states with a distinct recovery action, or cut to three `ResponseAvailable` as a _persisted_ state buys nothing today: recovery must re-derive the outcome from the projection anyway (finding 5), and dedup only needs "posted". The current stores run on effectively three states (`received`/`processing`/`completed`) plus nullable message IDs. Keep five states only if each maps to a distinct crash-recovery behavior — writing that table down (state → recovery action) is the cheapest way to force findings 1/2/5 to resolution. If a state has no recovery action of its own, it is a log line, not a state. -### 16. Drop the tag factories until something resolves them from context +### 13. Drop the tag factories until something resolves them from context Adapter → processor → handler is a straight constructor chain assembled once at bootstrap; only the HTTP route plausibly needs a context tag. Three string-keyed generic tag factories (`makeNTBS*Tag(key)`) also deviate from the codebase convention (`class X extends Context.Service()("t3/…")` with namespaced IDs) and nothing type-checks that two call sites will not collide on a bare key like `"jira"`. If kept, namespace the keys. (`Context.Service(key)` is a legitimate effect-smol form, so the factories are _sound_, just probably unnecessary.) -### 17. Design the error taxonomy around retryability, not strings +### 14. Design the error taxonomy around retryability, not strings `AdapterError { reason: string }` / `NTBSProcessorError { reason: string }` erase the one distinction the whole outbox pattern turns on: transient (429, network) vs. permanent (403, deleted comment). The current `JiraAppClient` already encodes retry-vs-fallback decisions (400/404 → try next parent). "Will be refined later" is noted in `adapter.ts` — but retryability is the _first_ refinement, and it changes method signatures, so decide it before adapters exist. -### 18. Do not collapse the outcome to `text` before the adapter sees it +### 15. Do not collapse the outcome to `text` before the adapter sees it `resolveT3Outcome` returns bare text and `postResponse(event, text)` posts it (`processor.ts:140-145`, `adapter.ts:55-58`). The docs distinguish answer/failure/timeout/cancellation and adapters own platform rendering — yet the one place rendering matters (a failure vs. an answer) arrives pre-flattened, while ack copy is fully adapter-owned. Asymmetric. Pass a small tagged outcome (`{ kind: "answer" | "failure" | "interrupted" | "timeout"; text }`) and let the adapter format. -### 19. Naming/file nits, worth fixing while it is cheap +### 16. Naming/file nits, worth fixing while it is cheap - `schemas.ts` contains no `effect/Schema` schemas — misleading in a repo where "schema" means that specifically (`packages/contracts` is "schema-only"); call it `lifecycle.ts`. - Three different things are called "event" in one file (platform events, T3 events, and these persisted _states_ with event-shaped names like `"thread.started"`); the base type `LifecycleEvent` is a state/record, and its docstring still says "NTBSEvent" (`schemas.ts:18`) while the docs say `NtbsEvent` and the state names diverge from the doc's (`acknowledgementPosted` vs `thread.started.acknowledged`). From c3c22fd8fc52a757d5b42691c247b2998b4a9319 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 8 Aug 2026 17:17:14 +0200 Subject: [PATCH 029/110] chore: finish reviewing first 4 points of adversarial review --- docs/planning/fixes.md | 6 ++++ docs/planning/ntbs-adversarial-review.md | 40 +++++++++--------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md index 2e70003758d1..9b985eabe415 100644 --- a/docs/planning/fixes.md +++ b/docs/planning/fixes.md @@ -17,3 +17,9 @@ The acknowledgement is a platform message such as "working on it." It improves f After creating the T3 thread, the processor records `ThreadStarted`. Starting the T3 work and attempting to post the acknowledgement are then independent operations. A failed acknowledgement must not prevent the work from starting, completing, or returning its final response. Remove `ThreadStartedAcknowledgement` from the shared lifecycle and remove `acknowledgementMessageId` from later lifecycle states. An adapter may retain the acknowledgement ID in its own storage and retry posting when appropriate, but final-response processing must not depend on it. + +## 3. Resolve the response for the correct T3 message + +The processor currently stores only the T3 thread ID and reads the latest turn when looking for the final response. If someone continues that thread from a native T3 client, the latest turn may belong to different work and its answer could be posted back to the original external request. + +Store the ID of the first T3 user message with `ThreadStarted`. Resolve the final response for that specific message instead of reading the latest turn in the thread. Record the corresponding turn ID later when T3 provides it. diff --git a/docs/planning/ntbs-adversarial-review.md b/docs/planning/ntbs-adversarial-review.md index 78e6f5127d51..7124bffea9cd 100644 --- a/docs/planning/ntbs-adversarial-review.md +++ b/docs/planning/ntbs-adversarial-review.md @@ -12,23 +12,13 @@ Findings are ordered by severity within each section and numbered globally for r ## A. Contract holes — these produce stuck/wrong external state if implemented as declared -### 1. Outcome detection is anchored on "latest turn", which is wrong the moment anyone touches the thread - -`t3Data` keeps only `threadId` (`schemas.ts:29-34`) and `resolveT3Outcome` reads "the latest turn" from the projection (`processor.ts:140-145`). NTBS threads are ordinary threads — visible in native clients, no lock. If a human opens one and sends a follow-up (or a queued message drains), `latestTurn` now describes _their_ turn: the processor will either post nothing or post the second turn's answer to the Jira comment. - -This regresses against both the NTBS docs (ntbs-architecture.md's record keeps `threadId`/`userMessageId`/`turnId`; ntbs-plan.md explicitly says "the lifecycle types must represent that sequence accurately") and the current implementation (`JiraDeliveryStore` keeps `userMessageId`, `previousTurnId`, `targetTurnId` and the bridges do targeted turn discovery). - -`userMessageId` is free — the processor generates it at dispatch. `turnId` genuinely arrives later (the provider adapter mints it; the decider emits `thread.turn-start-requested` with `turnId: null`). - -**Fix:** store `userMessageId` at `ThreadStarted`, resolve the outcome for the turn that answered _that message_, and record the discovered `turnId` when it appears. - -### 2. No timeout anywhere — a silently hung provider leaves an ack dangling forever +### 1. No timeout anywhere — a silently hung provider leaves an ack dangling forever Orchestration has no turn timeout (only 5s/45s provider-_control_ timeouts in `ProviderCommandReactor`), and if an ACP connection drops silently there is no `thread.session-set` until a server restart triggers `OrphanSessionRecovery`. The current Jira bridge covers this with a configurable 30-minute poll deadline plus a "still working" fallback message (`JiraIssueBridge.ts:504-559`). The skeleton has no deadline concept, yet ntbs.md promises "failure, timeout, or cancellation returns a response that explicitly reports the outcome." **Fix:** the processor (platform-independent) owns a per-record deadline; on expiry it posts a timeout outcome and marks the record posted — and document that a late real answer is then dropped, or define a supersede rule. -### 3. The event subscription cannot survive a restart, and nothing compensates +### 2. The event subscription cannot survive a restart, and nothing compensates `subscribeToT3Events` necessarily rides `OrchestrationEngine.streamDomainEvents`, which is an in-memory PubSub — "hot runtime stream (new events only)" (`orchestration/Services/OrchestrationEngine.ts:52-57`). Any turn that completes while the server (or just the processor fiber) is down emits into the void. `readEvents(fromSequenceExclusive)` exists, but the skeleton stores no cursor anywhere, and the engine's own docstring warns against stale-cursor replay. @@ -36,13 +26,13 @@ Orchestration has no turn timeout (only 5s/45s provider-_control_ timeouts in `P This is also the honest framing of a half-made design choice: the current bridges _poll_ per-delivery, which gets recovery and timeouts almost for free at the cost of 1s ticks. Event-wakeup + projection-truth + startup sweep is a fine steady state, but say so explicitly — the mechanism is currently smeared across `subscribeToT3Events`' docstring. -### 4. Double-posting is possible and undocumented +### 3. Double-posting is possible and undocumented Crash between a successful `postResponse` and `save(ResponsePosted)` → recovery re-posts. Platforms have no idempotent comment-create, so this is inherent at-least-once delivery. The current bridge has the same window; that is acceptable — but the adapter contract should state which semantics adapters must expect, because it changes what `save` failures mean. ## B. The codebase already has things the skeleton re-declares or ignores -### 5. The turn-start bootstrap already exists as a single command — do not rebuild it from Git primitives +### 4. The turn-start bootstrap already exists as a single command — do not rebuild it from Git primitives `ThreadTurnStartCommand.bootstrap` covers createThread + prepareWorktree + runSetupScript + first message + turn start (`packages/contracts/src/orchestration.ts:744-803`). Its server-side executor is currently inlined in `ws.ts:816-1168` (`dispatchBootstrapTurnStart`), including subtleties the skeleton would otherwise re-learn the hard way: `thread.meta.update` after worktree creation, polling the projection until the worktree is _visible_ before provider start (`ws.ts:953`), setup-script activity records, `startFromOrigin` fetch/resolve. @@ -50,7 +40,7 @@ The skeleton instead declares its own `createT3Thread`/`startT3Turn` split depen **Fix:** do the extraction; the processor's dependency list shrinks to OrchestrationEngine + ProjectionSnapshotQuery + the extracted service. One nuance to encode there: ws.ts _continues_ on worktree-prep failure (`ws.ts:879`), but NTBS must _fail_ the event instead — isolation is an agreed hard requirement (ntbs.md §concurrency). The shared service needs a strictness knob. -### 6. Provenance is first-class in T3 and the skeleton cannot carry it +### 5. Provenance is first-class in T3 and the skeleton cannot carry it `SourceChannel` already enumerates `discord`/`github`/`jira`/`slack`/`teams` (`packages/contracts/src/identity.ts:60-73`), `SourceRef`/`SourceLocation` carry issueKey/owner/repo/number/guildId (`identity.ts:75-116`), `ThreadTurnStartCommand` has `source`/`sourceHint` seats (`orchestration.ts:796-801`), threads have `originSource` (`orchestration.ts:443`), and the current bridges stamp it via `buildIntegrationSourceRef` (`identity/stampSource.ts:175`). @@ -58,49 +48,49 @@ The skeleton instead declares its own `createT3Thread`/`startT3Turn` split depen **Fix:** add `source`/`sourceHint` to `T3Context` (or the processor's dispatch), and reword the doc's opacity principle to "T3 never interprets platform data for lifecycle/routing decisions." -### 7. `T3Context` is missing everything else a thread needs, with no stated defaulting policy +### 6. `T3Context` is missing everything else a thread needs, with no stated defaulting policy Thread creation requires `title`, `modelSelection`, `runtimeMode`, `interactionMode` (`orchestration.ts:627-641`). The current bridge resolves model as `project.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection()`, title from the issue summary, runtimeMode `"full-access"` (`JiraIssueBridge.ts:396-399`). Either `T3Context` grows optional overrides (platforms will eventually want them — e.g. a Jira label selecting a model) or the processor owns the defaulting policy; right now neither is written down. Same for `revision` — is it a branch name or pinned SHA, resolved at accept-time or worktree-time? The current bridge resolves `origin/` at worktree time. -### 8. `snapshot: string` will hit the 120k input cap and silently forecloses attachments +### 7. `snapshot: string` will hit the 120k input cap and silently forecloses attachments `PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000` (`orchestration.ts:145`) — a captured Jira issue + comment history or a PR diff snapshot can exceed it, and then the dispatch fails and the lifecycle wedges (see finding 1). The processor must own a truncation policy. Separately, turn messages support image attachments (`ChatAttachment`), and Jira/GitHub/Discord invocations routinely include screenshots; `snapshot: string` forecloses them. Punting is fine — write the exclusion into the lifecycle module so it is a decision, not an accident. -### 9. Actor trust has no home +### 8. Actor trust has no home `classifyJiraActorTrust`/`githubActorTrust` gate who may trigger work, with a "context-only" fallback that _posts a note instead of starting work_. The platform-handler docstring ("determines whether the input should start work", `platform-handler.ts:8-14`) implicitly absorbs trust but never names it, and the context-only path — an outbound platform post with no lifecycle — fits neither handler nor adapter contract as written. It is fine to keep it platform-side and outside the lifecycle; say so explicitly, because it is a security boundary. ## C. Simplifications — things to delete or merge -### 10. `platform-handler.ts` is a vacuous abstraction — delete it +### 9. `platform-handler.ts` is a vacuous abstraction — delete it `NTBSPlatformHandler` is `handle: Input => Effect` (`platform-handler.ts:15-17`). Nothing consumes it polymorphically and nothing ever will — each platform's HTTP route calls its own handler with its own `Input` type; a generic interface over an existential `Input` has no call sites by construction. It is a function type with a ceremony tag factory. The real architecture is two-piece (processor + adapter); the "handler" is just each adapter package's inbound edge, and a comment in `processor.ts` already documents that convention adequately. -### 11. Collapse `ProcessorEvent` — the union wraps two statically-known callers +### 10. Collapse `ProcessorEvent` — the union wraps two statically-known callers `{ source: "adapter" } | { source: "t3" }` (`processor.ts:41-50`) has exactly one producer per arm, and the `"t3"` arm's only legitimate producer is the processor's _own_ subscription. Exposing it invites outsiders to inject synthetic T3 events, and `"adapter"` is a misnomer anyway (the platform handler builds it, not the adapter). Replace with `handleRequest(request, t3Context)` plus the internal subscription; tests can fake the engine's stream through the service dependency instead of injecting events through the front door. Also reconsider exposing `subscribeToT3Events` at all — if callers must wire it, name it for what it is (`run`/`daemon`); its current docstring leaks a private function name (`processT3Event`). -### 12. Justify each of the five states with a distinct recovery action, or cut to three +### 11. Justify each of the five states with a distinct recovery action, or cut to three `ResponseAvailable` as a _persisted_ state buys nothing today: recovery must re-derive the outcome from the projection anyway (finding 5), and dedup only needs "posted". The current stores run on effectively three states (`received`/`processing`/`completed`) plus nullable message IDs. Keep five states only if each maps to a distinct crash-recovery behavior — writing that table down (state → recovery action) is the cheapest way to force findings 1/2/5 to resolution. If a state has no recovery action of its own, it is a log line, not a state. -### 13. Drop the tag factories until something resolves them from context +### 12. Drop the tag factories until something resolves them from context Adapter → processor → handler is a straight constructor chain assembled once at bootstrap; only the HTTP route plausibly needs a context tag. Three string-keyed generic tag factories (`makeNTBS*Tag(key)`) also deviate from the codebase convention (`class X extends Context.Service()("t3/…")` with namespaced IDs) and nothing type-checks that two call sites will not collide on a bare key like `"jira"`. If kept, namespace the keys. (`Context.Service(key)` is a legitimate effect-smol form, so the factories are _sound_, just probably unnecessary.) -### 14. Design the error taxonomy around retryability, not strings +### 13. Design the error taxonomy around retryability, not strings `AdapterError { reason: string }` / `NTBSProcessorError { reason: string }` erase the one distinction the whole outbox pattern turns on: transient (429, network) vs. permanent (403, deleted comment). The current `JiraAppClient` already encodes retry-vs-fallback decisions (400/404 → try next parent). "Will be refined later" is noted in `adapter.ts` — but retryability is the _first_ refinement, and it changes method signatures, so decide it before adapters exist. -### 15. Do not collapse the outcome to `text` before the adapter sees it +### 14. Do not collapse the outcome to `text` before the adapter sees it `resolveT3Outcome` returns bare text and `postResponse(event, text)` posts it (`processor.ts:140-145`, `adapter.ts:55-58`). The docs distinguish answer/failure/timeout/cancellation and adapters own platform rendering — yet the one place rendering matters (a failure vs. an answer) arrives pre-flattened, while ack copy is fully adapter-owned. Asymmetric. Pass a small tagged outcome (`{ kind: "answer" | "failure" | "interrupted" | "timeout"; text }`) and let the adapter format. -### 16. Naming/file nits, worth fixing while it is cheap +### 15. Naming/file nits, worth fixing while it is cheap - `schemas.ts` contains no `effect/Schema` schemas — misleading in a repo where "schema" means that specifically (`packages/contracts` is "schema-only"); call it `lifecycle.ts`. - Three different things are called "event" in one file (platform events, T3 events, and these persisted _states_ with event-shaped names like `"thread.started"`); the base type `LifecycleEvent` is a state/record, and its docstring still says "NTBSEvent" (`schemas.ts:18`) while the docs say `NtbsEvent` and the state names diverge from the doc's (`acknowledgementPosted` vs `thread.started.acknowledged`). From d04c510e4e87170210fe67833d9b80b53ecce8f2 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 01:53:10 +0200 Subject: [PATCH 030/110] chore: finalize review --- docs/planning/fixes.md | 115 +++++++++++++++++++++++ docs/planning/ntbs-adversarial-review.md | 93 +----------------- 2 files changed, 116 insertions(+), 92 deletions(-) diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md index 9b985eabe415..f4c1c00038ee 100644 --- a/docs/planning/fixes.md +++ b/docs/planning/fixes.md @@ -23,3 +23,118 @@ Remove `ThreadStartedAcknowledgement` from the shared lifecycle and remove `ackn The processor currently stores only the T3 thread ID and reads the latest turn when looking for the final response. If someone continues that thread from a native T3 client, the latest turn may belong to different work and its answer could be posted back to the original external request. Store the ID of the first T3 user message with `ThreadStarted`. Resolve the final response for that specific message instead of reading the latest turn in the thread. Record the corresponding turn ID later when T3 provides it. + +## 4. Handle work that does not finish in time + +The processor currently waits forever for T3 to report that a turn has finished. If the provider hangs or disconnects without producing a completion event, the external platform never receives a final message. + +Check the target turn after 30 minutes: + +- If it is `running` or `starting`, wait another 15 minutes. +- If it is `completed`, retrieve and post its answer. +- If it is in `error`, `interrupted`, or `stopped`, post that failure without automatically rerunning the agent. +- If no turn ever started, retry the turn-start command once when it is safe to repeat. +- If the thread cannot be read because of a temporary failure, retry the status check rather than the T3 work. +- If the thread no longer exists, post an error and stop. + +If the turn is still running after 45 minutes, post that T3 is still working and include a link to the T3 thread. Then close the external response flow. A later answer remains available in T3 but is not posted automatically to the external platform. + +## 5. Recover missed T3 events after restart + +The processor only receives T3 events emitted while it is running. If T3 finishes work while the processor or server is down, the completion event is missed and the response would never be posted. + +Whenever the processor starts, load every unfinished NTBS record and check its current state in T3. Continue completed work, report failures, and resume waiting for work that is still running. Treat live T3 events as signals to check the current state, not as the only record of what happened. + +This recovery does not require storing an event cursor or replaying the T3 event log. T3's current state and the adapter's unfinished records provide enough information to continue. + +## 6. Verify an uncertain response before posting it again + +The adapter may successfully post a response and then stop before saving `ResponsePosted`. On recovery, it must not immediately post the response again. + +First inspect recent messages in the known response destination. Look for a message authored by the adapter, posted in the expected time frame, attached to the expected comment or thread, and containing the expected response. If it is found, save `ResponsePosted` using the existing platform message and continue without posting again. Retry `postResponse` only when that check finds no matching message. + +Each adapter owns the exact comparison because platforms may format, truncate, or split messages differently. + +## 7. REFUSED: Extract the turn-start bootstrap from `ws.ts` + +Do not extract this logic now. Implement the NTBS bootstrap separately, keep it aligned with `dispatchBootstrapTurnStart`, and replace it when upstream provides a reusable bootstrap service. This temporary duplication is easier to reconcile with upstream changes than a fork-specific refactor of `ws.ts`. + +## 8. Keep fork-specific provenance out of NTBS + +`SourceChannel`, `SourceRef`, `sourceHint`, `originSource`, and the related identity behavior were added by this fork. They are not part of upstream T3 and must not become requirements of the generic NTBS processor. + +Do not add these fields to `T3Context` or the NTBS lifecycle. The adapter already retains the platform data needed to connect an external message with its T3 work. Any integration with the fork's separate identity features can be handled outside the shared NTBS machinery. + +## 9. Define the shared thread defaults + +T3 requires an initial title, model selection, runtime mode, and interaction mode when creating a thread. `T3Context` currently provides only the project and revision, so the implementation would otherwise have to invent these choices or make each platform choose them independently. + +Keep `T3Context` limited to `projectId` and `revision`. The shared processor applies the same policy for every platform: + +- Create the thread with T3's default title and let the normal first-turn title generation replace it. +- Use the project's default model selection, falling back to T3's automatic bootstrap model selection. +- Use `full-access` runtime mode. +- Use `default` interaction mode. +- Treat `revision` as the Git ref from which the new isolated worktree starts, and resolve it when creating that worktree. + +Adapters provide the project and revision but do not choose or persist the remaining thread settings in the first implementation. + +## 10. Document the snapshot limit and review attachments + +Document on the `snapshot` field that it is used as the first T3 message and must fit T3's current 120,000-character input limit. Do not add truncation behavior yet. Review attachment support separately before implementation so that keeping `snapshot` text-only is an explicit decision rather than an accidental limitation. + +## 11. Preserve platform actor checks in the adapters + +The current Jira and GitHub integrations check whether the external account is allowed to start agent work, but the NTBS skeleton does not mention this behavior. Without carrying it into the new adapters, porting those integrations would silently remove an existing check. + +Each adapter must apply its platform-specific actor checks before sending a request to the shared processor. Input that fails those checks does not enter the NTBS lifecycle. The shared processor does not need an actor-trust model or any additional trust data. + +## 12. Remove the shared platform-handler abstraction + +`NTBSPlatformHandler` only states that a platform has a `handle` function. Each platform receives a different input type, and no shared code uses these handlers interchangeably, so the interface and its Effect tag add no shared behavior. + +Delete `platform-handler.ts`. Each platform adapter instead exposes its own concrete inbound function, such as a Jira webhook handler or Discord message handler. That function verifies and parses the platform input, applies its trigger and actor checks, builds the generic NTBS request and `T3Context`, and calls the shared processor. Keep `NTBSAdapter` limited to the storage and outbound operations used by the processor. + +## 13. Remove `ResponseAvailable` from the lifecycle + +`ResponseAvailable` does not currently enable a distinct recovery action because it stores no response. Recovery must still inspect T3 to determine the outcome, so persisting this extra transition adds little value. + +Keep the durable lifecycle limited to `ThreadStarted` and `ResponsePosted`. While a record remains `ThreadStarted`, the processor checks T3 and continues waiting or attempts to post the resolved outcome. After posting succeeds, it saves `ResponsePosted`. + +Add a durable pending-response state later only if real delivery retries require storing the exact outbound outcome independently from T3. + +## 14. Pass a response union to the adapter + +The processor currently reduces every T3 outcome to plain text before calling the adapter. The adapter therefore cannot distinguish a normal answer from a failure, timeout, or cancellation when applying its platform-specific rendering. + +Define a small response union with `answer`, `failure`, `timeout`, and `cancellation` cases, each carrying its response text. The processor determines which case occurred and passes it to `postResponse`; the adapter decides how that case is rendered on its platform. This response union is not an additional persisted lifecycle state. + +## 15. Make lifecycle naming consistent + +The current skeleton mixes schemas, events, records, and states when referring to the same stored lifecycle data. This makes the small lifecycle harder to understand and leaves the code and planning documents using different names. + +Rename `schemas.ts` to `lifecycle.ts`, because it contains TypeScript lifecycle types rather than Effect schemas. Use lifecycle-state terminology consistently in the file, comments, and dependent APIs. Align the code and documentation with the remaining `ThreadStarted` and `ResponsePosted` states, and fix the existing comment typos. Keep the established `NTBS` acronym casing. + +## 16. Archive the T3 thread after posting the response + +Every external request creates a new T3 thread and worktree. Leaving them open after the response has been posted would cause unused threads and worktrees to accumulate. + +After the response has been successfully posted and saved as `ResponsePosted`, archive its T3 thread so the normal T3 cleanup rules can remove the worktree. Treat archival as separate cleanup: if it fails, retry the archival without posting the response again. This can become a configurable retention policy later if a platform needs different behavior. + +## 17. Document concurrency limits without implementing them yet + +Many external requests arriving together can create many T3 threads, worktrees, and provider sessions at once. The first implementation will not add a cap, queue, or rejection policy. + +Document this limitation on the processor and defer the policy until real usage shows which limits are necessary. + +## 18. Keep remote adapters in mind + +The current NTBS shape assumes adapters run inside the T3 server and call the processor directly. This works for the initial Jira and GitHub adapters, but the current Discord bot runs as a separate program. + +When Discord is ported, either move its adapter into the server or expose the processor through a network API. Do not choose or implement that transport yet, but avoid making the lifecycle and storage design depend unnecessarily on every adapter sharing the server process. + +## 19. Defer snapshot retention to adapter implementation + +An adapter stores external content in `snapshot`, and that copy may remain after the original comment or message is deleted from its platform. + +Document this as a data-retention concern. Retention periods, storage limits, and handling source deletions are adapter implementation and deployment choices, so do not define them in the shared processor lifecycle yet. diff --git a/docs/planning/ntbs-adversarial-review.md b/docs/planning/ntbs-adversarial-review.md index 7124bffea9cd..cda07af01c18 100644 --- a/docs/planning/ntbs-adversarial-review.md +++ b/docs/planning/ntbs-adversarial-review.md @@ -10,99 +10,8 @@ The core seam is right — a shared lifecycle processor with per-platform adapte Findings are ordered by severity within each section and numbered globally for reference. -## A. Contract holes — these produce stuck/wrong external state if implemented as declared +## A. Decisions to make now (cheap in planning, expensive later) -### 1. No timeout anywhere — a silently hung provider leaves an ack dangling forever - -Orchestration has no turn timeout (only 5s/45s provider-_control_ timeouts in `ProviderCommandReactor`), and if an ACP connection drops silently there is no `thread.session-set` until a server restart triggers `OrphanSessionRecovery`. The current Jira bridge covers this with a configurable 30-minute poll deadline plus a "still working" fallback message (`JiraIssueBridge.ts:504-559`). The skeleton has no deadline concept, yet ntbs.md promises "failure, timeout, or cancellation returns a response that explicitly reports the outcome." - -**Fix:** the processor (platform-independent) owns a per-record deadline; on expiry it posts a timeout outcome and marks the record posted — and document that a late real answer is then dropped, or define a supersede rule. - -### 2. The event subscription cannot survive a restart, and nothing compensates - -`subscribeToT3Events` necessarily rides `OrchestrationEngine.streamDomainEvents`, which is an in-memory PubSub — "hot runtime stream (new events only)" (`orchestration/Services/OrchestrationEngine.ts:52-57`). Any turn that completes while the server (or just the processor fiber) is down emits into the void. `readEvents(fromSequenceExclusive)` exists, but the skeleton stores no cursor anywhere, and the engine's own docstring warns against stale-cursor replay. - -**No cursor is needed:** since `resolveT3Outcome` already treats the projection as the source of truth, the startup-recovery pass from finding 1 — re-check the projection for every incomplete record — also closes this hole. Treat the live stream purely as a wake-up signal. - -This is also the honest framing of a half-made design choice: the current bridges _poll_ per-delivery, which gets recovery and timeouts almost for free at the cost of 1s ticks. Event-wakeup + projection-truth + startup sweep is a fine steady state, but say so explicitly — the mechanism is currently smeared across `subscribeToT3Events`' docstring. - -### 3. Double-posting is possible and undocumented - -Crash between a successful `postResponse` and `save(ResponsePosted)` → recovery re-posts. Platforms have no idempotent comment-create, so this is inherent at-least-once delivery. The current bridge has the same window; that is acceptable — but the adapter contract should state which semantics adapters must expect, because it changes what `save` failures mean. - -## B. The codebase already has things the skeleton re-declares or ignores - -### 4. The turn-start bootstrap already exists as a single command — do not rebuild it from Git primitives - -`ThreadTurnStartCommand.bootstrap` covers createThread + prepareWorktree + runSetupScript + first message + turn start (`packages/contracts/src/orchestration.ts:744-803`). Its server-side executor is currently inlined in `ws.ts:816-1168` (`dispatchBootstrapTurnStart`), including subtleties the skeleton would otherwise re-learn the hard way: `thread.meta.update` after worktree creation, polling the projection until the worktree is _visible_ before provider start (`ws.ts:953`), setup-script activity records, `startFromOrigin` fetch/resolve. - -The skeleton instead declares its own `createT3Thread`/`startT3Turn` split depending directly on `GitWorkflowService` + `ProjectSetupScriptRunner` + `Crypto` (`processor.ts:74-113`) — i.e., a third copy of the mechanic beside `ws.ts` and `JiraIssueBridge.createThreadForIssue` (which the plan itself calls "a reference, not the desired architecture"). The plan's step 2 — extract the ws.ts mechanic into a service both native clients and NTBS call — is the right move and the skeleton silently dropped it. - -**Fix:** do the extraction; the processor's dependency list shrinks to OrchestrationEngine + ProjectionSnapshotQuery + the extracted service. One nuance to encode there: ws.ts _continues_ on worktree-prep failure (`ws.ts:879`), but NTBS must _fail_ the event instead — isolation is an agreed hard requirement (ntbs.md §concurrency). The shared service needs a strictness knob. - -### 5. Provenance is first-class in T3 and the skeleton cannot carry it - -`SourceChannel` already enumerates `discord`/`github`/`jira`/`slack`/`teams` (`packages/contracts/src/identity.ts:60-73`), `SourceRef`/`SourceLocation` carry issueKey/owner/repo/number/guildId (`identity.ts:75-116`), `ThreadTurnStartCommand` has `source`/`sourceHint` seats (`orchestration.ts:796-801`), threads have `originSource` (`orchestration.ts:443`), and the current bridges stamp it via `buildIntegrationSourceRef` (`identity/stampSource.ts:175`). - -`T3Context = { projectId, revision }` (`processor.ts:36-39`) has no seat for any of this, so NTBS-created threads would lose origin badges, participant attribution, and identity-map resolution that native clients already render — a visible regression vs. today's Jira bridge. It also falsifies the architecture doc's "T3 does not receive or interpret platform data" absolutism: T3 already _stores and renders_ platform provenance; what it does not do is interpret it for routing. - -**Fix:** add `source`/`sourceHint` to `T3Context` (or the processor's dispatch), and reword the doc's opacity principle to "T3 never interprets platform data for lifecycle/routing decisions." - -### 6. `T3Context` is missing everything else a thread needs, with no stated defaulting policy - -Thread creation requires `title`, `modelSelection`, `runtimeMode`, `interactionMode` (`orchestration.ts:627-641`). The current bridge resolves model as `project.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection()`, title from the issue summary, runtimeMode `"full-access"` (`JiraIssueBridge.ts:396-399`). Either `T3Context` grows optional overrides (platforms will eventually want them — e.g. a Jira label selecting a model) or the processor owns the defaulting policy; right now neither is written down. Same for `revision` — is it a branch name or pinned SHA, resolved at accept-time or worktree-time? The current bridge resolves `origin/` at worktree time. - -### 7. `snapshot: string` will hit the 120k input cap and silently forecloses attachments - -`PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000` (`orchestration.ts:145`) — a captured Jira issue + comment history or a PR diff snapshot can exceed it, and then the dispatch fails and the lifecycle wedges (see finding 1). The processor must own a truncation policy. - -Separately, turn messages support image attachments (`ChatAttachment`), and Jira/GitHub/Discord invocations routinely include screenshots; `snapshot: string` forecloses them. Punting is fine — write the exclusion into the lifecycle module so it is a decision, not an accident. - -### 8. Actor trust has no home - -`classifyJiraActorTrust`/`githubActorTrust` gate who may trigger work, with a "context-only" fallback that _posts a note instead of starting work_. The platform-handler docstring ("determines whether the input should start work", `platform-handler.ts:8-14`) implicitly absorbs trust but never names it, and the context-only path — an outbound platform post with no lifecycle — fits neither handler nor adapter contract as written. It is fine to keep it platform-side and outside the lifecycle; say so explicitly, because it is a security boundary. - -## C. Simplifications — things to delete or merge - -### 9. `platform-handler.ts` is a vacuous abstraction — delete it - -`NTBSPlatformHandler` is `handle: Input => Effect` (`platform-handler.ts:15-17`). Nothing consumes it polymorphically and nothing ever will — each platform's HTTP route calls its own handler with its own `Input` type; a generic interface over an existential `Input` has no call sites by construction. It is a function type with a ceremony tag factory. The real architecture is two-piece (processor + adapter); the "handler" is just each adapter package's inbound edge, and a comment in `processor.ts` already documents that convention adequately. - -### 10. Collapse `ProcessorEvent` — the union wraps two statically-known callers - -`{ source: "adapter" } | { source: "t3" }` (`processor.ts:41-50`) has exactly one producer per arm, and the `"t3"` arm's only legitimate producer is the processor's _own_ subscription. Exposing it invites outsiders to inject synthetic T3 events, and `"adapter"` is a misnomer anyway (the platform handler builds it, not the adapter). Replace with `handleRequest(request, t3Context)` plus the internal subscription; tests can fake the engine's stream through the service dependency instead of injecting events through the front door. - -Also reconsider exposing `subscribeToT3Events` at all — if callers must wire it, name it for what it is (`run`/`daemon`); its current docstring leaks a private function name (`processT3Event`). - -### 11. Justify each of the five states with a distinct recovery action, or cut to three - -`ResponseAvailable` as a _persisted_ state buys nothing today: recovery must re-derive the outcome from the projection anyway (finding 5), and dedup only needs "posted". The current stores run on effectively three states (`received`/`processing`/`completed`) plus nullable message IDs. Keep five states only if each maps to a distinct crash-recovery behavior — writing that table down (state → recovery action) is the cheapest way to force findings 1/2/5 to resolution. If a state has no recovery action of its own, it is a log line, not a state. - -### 12. Drop the tag factories until something resolves them from context - -Adapter → processor → handler is a straight constructor chain assembled once at bootstrap; only the HTTP route plausibly needs a context tag. Three string-keyed generic tag factories (`makeNTBS*Tag(key)`) also deviate from the codebase convention (`class X extends Context.Service()("t3/…")` with namespaced IDs) and nothing type-checks that two call sites will not collide on a bare key like `"jira"`. If kept, namespace the keys. (`Context.Service(key)` is a legitimate effect-smol form, so the factories are _sound_, just probably unnecessary.) - -### 13. Design the error taxonomy around retryability, not strings - -`AdapterError { reason: string }` / `NTBSProcessorError { reason: string }` erase the one distinction the whole outbox pattern turns on: transient (429, network) vs. permanent (403, deleted comment). The current `JiraAppClient` already encodes retry-vs-fallback decisions (400/404 → try next parent). "Will be refined later" is noted in `adapter.ts` — but retryability is the _first_ refinement, and it changes method signatures, so decide it before adapters exist. - -### 14. Do not collapse the outcome to `text` before the adapter sees it - -`resolveT3Outcome` returns bare text and `postResponse(event, text)` posts it (`processor.ts:140-145`, `adapter.ts:55-58`). The docs distinguish answer/failure/timeout/cancellation and adapters own platform rendering — yet the one place rendering matters (a failure vs. an answer) arrives pre-flattened, while ack copy is fully adapter-owned. Asymmetric. Pass a small tagged outcome (`{ kind: "answer" | "failure" | "interrupted" | "timeout"; text }`) and let the adapter format. - -### 15. Naming/file nits, worth fixing while it is cheap - -- `schemas.ts` contains no `effect/Schema` schemas — misleading in a repo where "schema" means that specifically (`packages/contracts` is "schema-only"); call it `lifecycle.ts`. -- Three different things are called "event" in one file (platform events, T3 events, and these persisted _states_ with event-shaped names like `"thread.started"`); the base type `LifecycleEvent` is a state/record, and its docstring still says "NTBSEvent" (`schemas.ts:18`) while the docs say `NtbsEvent` and the state names diverge from the doc's (`acknowledgementPosted` vs `thread.started.acknowledged`). -- Typos: "response tex" (`processor.ts:83`), "idenitifier" (`adapter.ts:52`). -- Casing: `NTBSAdapter` vs the docs' `Ntbs`. - -## D. Decisions to make now (cheap in planning, expensive later) - -- **Post-response thread policy.** Thread-per-event with no terminal action means worktrees and inbox noise accumulate unboundedly — `WorktreeLifecycle` only cleans on archive, and nobody archives NTBS threads. The docs acknowledge the noise but propose nothing. Decide: auto-settle (or archive) after `ResponsePosted`, keeping worktree-retention rules in one place. -- **Concurrency caps.** A chatty Jira issue or Discord thread can fork-bomb worktrees + provider sessions. The cap/queue/reject policy is platform-independent and belongs in the processor; the current bridge only bounds _recovery_ concurrency (4). -- **In-process vs. remote adapters.** The skeleton is in-process Effect services; Jira/GitHub webhooks fit, but today's Discord integration is an external bot speaking WS with `sourceHint` (`identity/stampSource.ts:2-4`). ntbs.md's own framing ("adapters should be able to obtain an initial state and then receive subsequent changes") describes a _protocol_, not an in-process interface. Building in-process first is fine — but state that the Discord port means either moving the bot in-server or exposing the processor over a transport, so nobody bakes in-process assumptions into the lifecycle store. -- **Ack-before-turn ordering.** The skeleton posts the ack before starting the turn (`processor.ts:117-123`); the plan doc ordered turn-start first. Ack-first is currently _forced_ by finding 2's type constraint and costs a platform round-trip of agent latency on every event. If `acknowledgementMessageId` becomes optional in response states, the ordering becomes free — choose it deliberately rather than inheriting it from the type shape. - **Snapshot retention.** Adapters persist external user content (snapshots) indefinitely; platforms let users delete messages. The docs punt to adapters — fine, but record it as a known compliance question, and note the current store's ~2000-record cap as prior art. ## What is right (keep it) From 2c03c909aaa09a349b00df2d4fc46f186600bb40 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 10:37:24 +0200 Subject: [PATCH 031/110] feat: write adversarial review --- docs/planning/ntbs-adversarial-review.md | 31 ------------------------ 1 file changed, 31 deletions(-) delete mode 100644 docs/planning/ntbs-adversarial-review.md diff --git a/docs/planning/ntbs-adversarial-review.md b/docs/planning/ntbs-adversarial-review.md deleted file mode 100644 index cda07af01c18..000000000000 --- a/docs/planning/ntbs-adversarial-review.md +++ /dev/null @@ -1,31 +0,0 @@ -# NTBS skeleton adversarial review - -**Status:** review of the declaration-phase skeleton in `apps/server/src/ntbs` - -**Scope:** `schemas.ts`, `processor.ts`, `platform-handler.ts`, `adapter.ts`, reviewed against [ntbs.md](./ntbs.md), [ntbs-architecture.md](./ntbs-architecture.md), [ntbs-event-processing.md](./ntbs-event-processing.md), [ntbs-plan.md](./ntbs-plan.md), and the existing implementation (`apps/server/src/jira`, `apps/server/src/github`, `apps/server/src/ws.ts`, `apps/server/src/orchestration`, `packages/contracts`). - -## Verdict - -The core seam is right — a shared lifecycle processor with per-platform adapters is exactly what `JiraIssueBridge` and `GitHubPrBridge` already share informally (they import each other's outcome-resolution helpers), and thread-per-event kills the hairiest logic in the current bridges (thread reuse, turn targeting against a shared thread). But the skeleton as declared has **two liveness holes that make it unimplementable as specified**, quietly **regresses five capabilities the current bridges already have**, **re-declares a mechanic the codebase already ships** (turn-start bootstrap), and carries at least three abstractions that can be deleted. - -Findings are ordered by severity within each section and numbered globally for reference. - -## A. Decisions to make now (cheap in planning, expensive later) - -- **Snapshot retention.** Adapters persist external user content (snapshots) indefinitely; platforms let users delete messages. The docs punt to adapters — fine, but record it as a known compliance question, and note the current store's ~2000-record cap as prior art. - -## What is right (keep it) - -- Thread-per-event genuinely deletes the worst code in the current bridges (`resolveLinkedThreadId`, ambiguous-link handling, target-turn discovery against shared threads). -- The adapter surface (`accept`/`save`/`find`/`post*`) is small, in-memory-fakeable, and matches the plan's testing strategy. -- Keeping platform data opaque-generic (`PlatformData`) while the processor owns sequencing is the correct division — every platform-independent behavior identified in the Jira bridge analysis fits it once findings 1–5 are fixed. - -## Summary - -The skeleton is a good shape wrapped around an incomplete failure model: - -1. Fix the recovery story (findings 1, 5), the ack dead-end (2), turn anchoring (3), and timeouts (4) in the contract now. -2. Reuse the bootstrap command and provenance plumbing instead of re-declaring them (7, 8). -3. Delete the platform-handler layer (12). - -Update [ntbs-architecture.md](./ntbs-architecture.md) alongside — several findings (3, 8) are places where the skeleton diverged from decisions the docs already got right. From 7b7e776f9c591e58e4ad308bcbb1581874d4cf27 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 11:12:40 +0200 Subject: [PATCH 032/110] chore: document concurrency of NTBSProcessor --- apps/server/src/ntbs/processor.ts | 3 + docs/planning/fixes.md | 136 ++++++++++++++---------------- 2 files changed, 66 insertions(+), 73 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index d7e5e746e427..1d405409a2e9 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -56,6 +56,9 @@ export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ export interface NTBSProcessor

{ /** * Routes adapter requests and T3 events through the shared NTBS workflow. + * + * `process` accepts concurrent requests and applies no queue, concurrency cap + * or backpressure for the time being. This choice can be reviewed later. */ readonly process: (event: ProcessorEvent

) => Effect.Effect; diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md index f4c1c00038ee..1abfb70ccd2e 100644 --- a/docs/planning/fixes.md +++ b/docs/planning/fixes.md @@ -2,70 +2,71 @@ This document collects the units of work identified by the adversarial review of the NTBS design. -## 1. Remove the pre-thread lifecycle state +## 1. Defer snapshot retention to adapter implementation -`RequestAccepted` exists to recover a request when the server stops before recording `ThreadStarted`. Supporting that narrow failure window requires planned thread IDs, searches for unfinished requests, startup retries, and rules for resuming duplicates. +An adapter stores external content in `snapshot`, and that copy may remain after the original comment or message is deleted from its platform. -Do not add that machinery in the first implementation. Remove `RequestAccepted` and make `ThreadStarted` the first stored lifecycle state. Record it as soon as the basic T3 thread exists, before slower worktree preparation or project setup begins. +Document this as a data-retention concern. Retention periods, storage limits, and handling source deletions are adapter implementation and deployment choices, so do not define them in the shared processor lifecycle yet. -This deliberately accepts one limitation: if the server stops before `ThreadStarted` is saved, the request may be lost. The user receives no acknowledgement and can send the request again. If this becomes a real problem, each adapter can later inspect recent platform messages and recover missing requests using the capabilities of that platform. +## 2. Keep fork-specific provenance out of NTBS -## 2. Make acknowledgements independent from the shared lifecycle +`SourceChannel`, `SourceRef`, `sourceHint`, `originSource`, and the related identity behavior were added by this fork. They are not part of upstream T3 and must not become requirements of the generic NTBS processor. -The acknowledgement is a platform message such as "working on it." It improves feedback for the user, but the current types make its message ID mandatory for `ResponseAvailable` and `ResponsePosted`. If posting the acknowledgement fails, the processor cannot represent or post the final response even though T3 work can continue. +Do not add these fields to `T3Context` or the NTBS lifecycle. The adapter already retains the platform data needed to connect an external message with its T3 work. Any integration with the fork's separate identity features can be handled outside the shared NTBS machinery. -After creating the T3 thread, the processor records `ThreadStarted`. Starting the T3 work and attempting to post the acknowledgement are then independent operations. A failed acknowledgement must not prevent the work from starting, completing, or returning its final response. +## 3. Document the snapshot limit and review attachments -Remove `ThreadStartedAcknowledgement` from the shared lifecycle and remove `acknowledgementMessageId` from later lifecycle states. An adapter may retain the acknowledgement ID in its own storage and retry posting when appropriate, but final-response processing must not depend on it. +Document on the `snapshot` field that it is used as the first T3 message and must fit T3's current 120,000-character input limit. Do not add truncation behavior yet. Review attachment support separately before implementation so that keeping `snapshot` text-only is an explicit decision rather than an accidental limitation. -## 3. Resolve the response for the correct T3 message +## 4. Make lifecycle naming consistent -The processor currently stores only the T3 thread ID and reads the latest turn when looking for the final response. If someone continues that thread from a native T3 client, the latest turn may belong to different work and its answer could be posted back to the original external request. +The current skeleton mixes schemas, events, records, and states when referring to the same stored lifecycle data. This makes the small lifecycle harder to understand and leaves the code and planning documents using different names. -Store the ID of the first T3 user message with `ThreadStarted`. Resolve the final response for that specific message instead of reading the latest turn in the thread. Record the corresponding turn ID later when T3 provides it. +Rename `schemas.ts` to `lifecycle.ts`, because it contains TypeScript lifecycle types rather than Effect schemas. Use lifecycle-state terminology consistently in the file, comments, and dependent APIs. Align the code and documentation with the remaining `ThreadStarted` and `ResponsePosted` states, and fix the existing comment typos. Keep the established `NTBS` acronym casing. -## 4. Handle work that does not finish in time +## 5. Preserve platform actor checks in the adapters -The processor currently waits forever for T3 to report that a turn has finished. If the provider hangs or disconnects without producing a completion event, the external platform never receives a final message. +The current Jira and GitHub integrations check whether the external account is allowed to start agent work, but the NTBS skeleton does not mention this behavior. Without carrying it into the new adapters, porting those integrations would silently remove an existing check. -Check the target turn after 30 minutes: +Each adapter must apply its platform-specific actor checks before sending a request to the shared processor. Input that fails those checks does not enter the NTBS lifecycle. The shared processor does not need an actor-trust model or any additional trust data. -- If it is `running` or `starting`, wait another 15 minutes. -- If it is `completed`, retrieve and post its answer. -- If it is in `error`, `interrupted`, or `stopped`, post that failure without automatically rerunning the agent. -- If no turn ever started, retry the turn-start command once when it is safe to repeat. -- If the thread cannot be read because of a temporary failure, retry the status check rather than the T3 work. -- If the thread no longer exists, post an error and stop. +## 6. Remove the shared platform-handler abstraction -If the turn is still running after 45 minutes, post that T3 is still working and include a link to the T3 thread. Then close the external response flow. A later answer remains available in T3 but is not posted automatically to the external platform. +`NTBSPlatformHandler` only states that a platform has a `handle` function. Each platform receives a different input type, and no shared code uses these handlers interchangeably, so the interface and its Effect tag add no shared behavior. -## 5. Recover missed T3 events after restart +Delete `platform-handler.ts`. Each platform adapter instead exposes its own concrete inbound function, such as a Jira webhook handler or Discord message handler. That function verifies and parses the platform input, applies its trigger and actor checks, builds the generic NTBS request and `T3Context`, and calls the shared processor. Keep `NTBSAdapter` limited to the storage and outbound operations used by the processor. -The processor only receives T3 events emitted while it is running. If T3 finishes work while the processor or server is down, the completion event is missed and the response would never be posted. +## 7. Pass a response union to the adapter -Whenever the processor starts, load every unfinished NTBS record and check its current state in T3. Continue completed work, report failures, and resume waiting for work that is still running. Treat live T3 events as signals to check the current state, not as the only record of what happened. +The processor currently reduces every T3 outcome to plain text before calling the adapter. The adapter therefore cannot distinguish a normal answer from a failure, timeout, or cancellation when applying its platform-specific rendering. -This recovery does not require storing an event cursor or replaying the T3 event log. T3's current state and the adapter's unfinished records provide enough information to continue. +Define a small response union with `answer`, `failure`, `timeout`, and `cancellation` cases, each carrying its response text. The processor determines which case occurred and passes it to `postResponse`; the adapter decides how that case is rendered on its platform. This response union is not an additional persisted lifecycle state. -## 6. Verify an uncertain response before posting it again +## 8. Remove `ResponseAvailable` from the lifecycle -The adapter may successfully post a response and then stop before saving `ResponsePosted`. On recovery, it must not immediately post the response again. +`ResponseAvailable` does not currently enable a distinct recovery action because it stores no response. Recovery must still inspect T3 to determine the outcome, so persisting this extra transition adds little value. -First inspect recent messages in the known response destination. Look for a message authored by the adapter, posted in the expected time frame, attached to the expected comment or thread, and containing the expected response. If it is found, save `ResponsePosted` using the existing platform message and continue without posting again. Retry `postResponse` only when that check finds no matching message. +Keep the durable lifecycle limited to `ThreadStarted` and `ResponsePosted`. While a record remains `ThreadStarted`, the processor checks T3 and continues waiting or attempts to post the resolved outcome. After posting succeeds, it saves `ResponsePosted`. -Each adapter owns the exact comparison because platforms may format, truncate, or split messages differently. +Add a durable pending-response state later only if real delivery retries require storing the exact outbound outcome independently from T3. -## 7. REFUSED: Extract the turn-start bootstrap from `ws.ts` +## 9. Make acknowledgements independent from the shared lifecycle -Do not extract this logic now. Implement the NTBS bootstrap separately, keep it aligned with `dispatchBootstrapTurnStart`, and replace it when upstream provides a reusable bootstrap service. This temporary duplication is easier to reconcile with upstream changes than a fork-specific refactor of `ws.ts`. +The acknowledgement is a platform message such as "working on it." It improves feedback for the user, but the current types make its message ID mandatory for `ResponseAvailable` and `ResponsePosted`. If posting the acknowledgement fails, the processor cannot represent or post the final response even though T3 work can continue. -## 8. Keep fork-specific provenance out of NTBS +After creating the T3 thread, the processor records `ThreadStarted`. Starting the T3 work and attempting to post the acknowledgement are then independent operations. A failed acknowledgement must not prevent the work from starting, completing, or returning its final response. -`SourceChannel`, `SourceRef`, `sourceHint`, `originSource`, and the related identity behavior were added by this fork. They are not part of upstream T3 and must not become requirements of the generic NTBS processor. +Remove `ThreadStartedAcknowledgement` from the shared lifecycle and remove `acknowledgementMessageId` from later lifecycle states. An adapter may retain the acknowledgement ID in its own storage and retry posting when appropriate, but final-response processing must not depend on it. -Do not add these fields to `T3Context` or the NTBS lifecycle. The adapter already retains the platform data needed to connect an external message with its T3 work. Any integration with the fork's separate identity features can be handled outside the shared NTBS machinery. +## 10. Remove the pre-thread lifecycle state + +`RequestAccepted` exists to recover a request when the server stops before recording `ThreadStarted`. Supporting that narrow failure window requires planned thread IDs, searches for unfinished requests, startup retries, and rules for resuming duplicates. -## 9. Define the shared thread defaults +Do not add that machinery in the first implementation. Remove `RequestAccepted` and make `ThreadStarted` the first stored lifecycle state. Record it as soon as the basic T3 thread exists, before slower worktree preparation or project setup begins. + +This deliberately accepts one limitation: if the server stops before `ThreadStarted` is saved, the request may be lost. The user receives no acknowledgement and can send the request again. If this becomes a real problem, each adapter can later inspect recent platform messages and recover missing requests using the capabilities of that platform. + +## 11. Define the shared thread defaults T3 requires an initial title, model selection, runtime mode, and interaction mode when creating a thread. `T3Context` currently provides only the project and revision, so the implementation would otherwise have to invent these choices or make each platform choose them independently. @@ -79,62 +80,51 @@ Keep `T3Context` limited to `projectId` and `revision`. The shared processor app Adapters provide the project and revision but do not choose or persist the remaining thread settings in the first implementation. -## 10. Document the snapshot limit and review attachments - -Document on the `snapshot` field that it is used as the first T3 message and must fit T3's current 120,000-character input limit. Do not add truncation behavior yet. Review attachment support separately before implementation so that keeping `snapshot` text-only is an explicit decision rather than an accidental limitation. - -## 11. Preserve platform actor checks in the adapters - -The current Jira and GitHub integrations check whether the external account is allowed to start agent work, but the NTBS skeleton does not mention this behavior. Without carrying it into the new adapters, porting those integrations would silently remove an existing check. - -Each adapter must apply its platform-specific actor checks before sending a request to the shared processor. Input that fails those checks does not enter the NTBS lifecycle. The shared processor does not need an actor-trust model or any additional trust data. - -## 12. Remove the shared platform-handler abstraction - -`NTBSPlatformHandler` only states that a platform has a `handle` function. Each platform receives a different input type, and no shared code uses these handlers interchangeably, so the interface and its Effect tag add no shared behavior. - -Delete `platform-handler.ts`. Each platform adapter instead exposes its own concrete inbound function, such as a Jira webhook handler or Discord message handler. That function verifies and parses the platform input, applies its trigger and actor checks, builds the generic NTBS request and `T3Context`, and calls the shared processor. Keep `NTBSAdapter` limited to the storage and outbound operations used by the processor. - -## 13. Remove `ResponseAvailable` from the lifecycle +## 12. Resolve the response for the correct T3 message -`ResponseAvailable` does not currently enable a distinct recovery action because it stores no response. Recovery must still inspect T3 to determine the outcome, so persisting this extra transition adds little value. +The processor currently stores only the T3 thread ID and reads the latest turn when looking for the final response. If someone continues that thread from a native T3 client, the latest turn may belong to different work and its answer could be posted back to the original external request. -Keep the durable lifecycle limited to `ThreadStarted` and `ResponsePosted`. While a record remains `ThreadStarted`, the processor checks T3 and continues waiting or attempts to post the resolved outcome. After posting succeeds, it saves `ResponsePosted`. +Store the ID of the first T3 user message with `ThreadStarted`. Resolve the final response for that specific message instead of reading the latest turn in the thread. Record the corresponding turn ID later when T3 provides it. -Add a durable pending-response state later only if real delivery retries require storing the exact outbound outcome independently from T3. +## 13. Archive the T3 thread after posting the response -## 14. Pass a response union to the adapter +Every external request creates a new T3 thread and worktree. Leaving them open after the response has been posted would cause unused threads and worktrees to accumulate. -The processor currently reduces every T3 outcome to plain text before calling the adapter. The adapter therefore cannot distinguish a normal answer from a failure, timeout, or cancellation when applying its platform-specific rendering. +After the response has been successfully posted and saved as `ResponsePosted`, archive its T3 thread so the normal T3 cleanup rules can remove the worktree. Treat archival as separate cleanup: if it fails, retry the archival without posting the response again. This can become a configurable retention policy later if a platform needs different behavior. -Define a small response union with `answer`, `failure`, `timeout`, and `cancellation` cases, each carrying its response text. The processor determines which case occurred and passes it to `postResponse`; the adapter decides how that case is rendered on its platform. This response union is not an additional persisted lifecycle state. +## 14. Keep remote adapters in mind -## 15. Make lifecycle naming consistent +The current NTBS shape assumes adapters run inside the T3 server and call the processor directly. This works for the initial Jira and GitHub adapters, but the current Discord bot runs as a separate program. -The current skeleton mixes schemas, events, records, and states when referring to the same stored lifecycle data. This makes the small lifecycle harder to understand and leaves the code and planning documents using different names. +When Discord is ported, either move its adapter into the server or expose the processor through a network API. Do not choose or implement that transport yet, but avoid making the lifecycle and storage design depend unnecessarily on every adapter sharing the server process. -Rename `schemas.ts` to `lifecycle.ts`, because it contains TypeScript lifecycle types rather than Effect schemas. Use lifecycle-state terminology consistently in the file, comments, and dependent APIs. Align the code and documentation with the remaining `ThreadStarted` and `ResponsePosted` states, and fix the existing comment typos. Keep the established `NTBS` acronym casing. +## 15. Verify an uncertain response before posting it again -## 16. Archive the T3 thread after posting the response +The adapter may successfully post a response and then stop before saving `ResponsePosted`. On recovery, it must not immediately post the response again. -Every external request creates a new T3 thread and worktree. Leaving them open after the response has been posted would cause unused threads and worktrees to accumulate. +First inspect recent messages in the known response destination. Look for a message authored by the adapter, posted in the expected time frame, attached to the expected comment or thread, and containing the expected response. If it is found, save `ResponsePosted` using the existing platform message and continue without posting again. Retry `postResponse` only when that check finds no matching message. -After the response has been successfully posted and saved as `ResponsePosted`, archive its T3 thread so the normal T3 cleanup rules can remove the worktree. Treat archival as separate cleanup: if it fails, retry the archival without posting the response again. This can become a configurable retention policy later if a platform needs different behavior. +Each adapter owns the exact comparison because platforms may format, truncate, or split messages differently. -## 17. Document concurrency limits without implementing them yet +## 16. Recover missed T3 events after restart -Many external requests arriving together can create many T3 threads, worktrees, and provider sessions at once. The first implementation will not add a cap, queue, or rejection policy. +The processor only receives T3 events emitted while it is running. If T3 finishes work while the processor or server is down, the completion event is missed and the response would never be posted. -Document this limitation on the processor and defer the policy until real usage shows which limits are necessary. +Whenever the processor starts, load every unfinished NTBS record and check its current state in T3. Continue completed work, report failures, and resume waiting for work that is still running. Treat live T3 events as signals to check the current state, not as the only record of what happened. -## 18. Keep remote adapters in mind +This recovery does not require storing an event cursor or replaying the T3 event log. T3's current state and the adapter's unfinished records provide enough information to continue. -The current NTBS shape assumes adapters run inside the T3 server and call the processor directly. This works for the initial Jira and GitHub adapters, but the current Discord bot runs as a separate program. +## 17. Handle work that does not finish in time -When Discord is ported, either move its adapter into the server or expose the processor through a network API. Do not choose or implement that transport yet, but avoid making the lifecycle and storage design depend unnecessarily on every adapter sharing the server process. +The processor currently waits forever for T3 to report that a turn has finished. If the provider hangs or disconnects without producing a completion event, the external platform never receives a final message. -## 19. Defer snapshot retention to adapter implementation +Check the target turn after 30 minutes: -An adapter stores external content in `snapshot`, and that copy may remain after the original comment or message is deleted from its platform. +- If it is `running` or `starting`, wait another 15 minutes. +- If it is `completed`, retrieve and post its answer. +- If it is in `error`, `interrupted`, or `stopped`, post that failure without automatically rerunning the agent. +- If no turn ever started, retry the turn-start command once when it is safe to repeat. +- If the thread cannot be read because of a temporary failure, retry the status check rather than the T3 work. +- If the thread no longer exists, post an error and stop. -Document this as a data-retention concern. Retention periods, storage limits, and handling source deletions are adapter implementation and deployment choices, so do not define them in the shared processor lifecycle yet. +If the turn is still running after 45 minutes, post that T3 is still working and include a link to the T3 thread. Then close the external response flow. A later answer remains available in T3 but is not posted automatically to the external platform. From a1dfae80553a4f93fb21d379e1fe1a64bf81e15b Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 17:06:43 +0200 Subject: [PATCH 033/110] feat: more updates and refactors of ntbs --- apps/server/src/ntbs/adapter.ts | 22 +++++----- apps/server/src/ntbs/lifecycle.ts | 51 +++++++++++++++++++++++ apps/server/src/ntbs/processor.ts | 62 ++++++++++++++------------- apps/server/src/ntbs/schemas.ts | 69 ------------------------------- docs/planning/fixes.md | 46 --------------------- docs/planning/ideas.md | 6 +++ 6 files changed, 101 insertions(+), 155 deletions(-) create mode 100644 apps/server/src/ntbs/lifecycle.ts delete mode 100644 apps/server/src/ntbs/schemas.ts diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index fdcbdf6ae2f6..5b89d6c42653 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -1,5 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; -import * as NTBS from "./schemas.ts"; +import * as NTBS from "./lifecycle.ts"; import { Context, Data, Effect } from "effect"; export class ThreadNotFound extends Data.TaggedError("ThreadNotFound") {} @@ -17,6 +17,11 @@ export class AdapterError extends Data.TaggedError("AdapterError")<{ * The adapter detects duplicate requests, stores lifecycle data, finds that data * from a T3 thread ID, and posts acknowledgements and responses. * + * The adapter owns its storage and retention policy. A stored snapshot may + * outlive the original platform message. E.g. a message on Discord gets deleted + * but its still persisted in the original snapshot. + * Verify retention policies. + * * It does not create T3 threads or interpret T3 events. */ export interface NTBSAdapter

{ @@ -24,11 +29,9 @@ export interface NTBSAdapter

{ * Stores the request before any T3 work begins. * * Returns `"duplicate"` if the same platform request was already stored. - * - * Returning `"accepted"` means this `RequestAccepted` state has been stored. */ readonly accept: ( - event: NTBS.RequestAccepted

, + event: NTBS.NTBSInput

, ) => Effect.Effect<"accepted" | "duplicate", AdapterError>; /** * Stores a lifecycle state. Does not perform any other business logic. @@ -39,11 +42,9 @@ export interface NTBSAdapter

{ * described by the event. * * Returns the platform's identifier for the posted message. - * - * The processor uses that identifier to save `ThreadStartedAcknowledgement`. */ readonly postAcknowledgement: ( - event: NTBS.ThreadStarted

, + state: NTBS.ThreadStarted

, ) => Effect.Effect; /** * Posts the final T3 outcome at the response destination described @@ -53,7 +54,7 @@ export interface NTBSAdapter

{ * The processor uses that identifier to save `ResponsePosted`. */ readonly postResponse: ( - event: NTBS.ResponseAvailable

, + state: NTBS.ThreadStarted

, text: string, ) => Effect.Effect; /** @@ -64,10 +65,7 @@ export interface NTBSAdapter

{ */ readonly findByThreadId: ( threadId: ThreadId, - ) => Effect.Effect< - Exclude, NTBS.RequestAccepted

>, - ThreadNotFound | AdapterError - >; + ) => Effect.Effect, ThreadNotFound | AdapterError>; } export const makeNTBSAdapterTag =

(key: string) => diff --git a/apps/server/src/ntbs/lifecycle.ts b/apps/server/src/ntbs/lifecycle.ts new file mode 100644 index 000000000000..49aaf86d5c23 --- /dev/null +++ b/apps/server/src/ntbs/lifecycle.ts @@ -0,0 +1,51 @@ +import type { ChatAttachment, ThreadId } from "@t3tools/contracts"; + +/** + * Describes the platform-specific data of a + * Non-Turn-Based-Surface. + * + * When receiving an NTBS event (a comment, a message tagging + * a bot, etc) `source` and `responseDestination` hold the details + * necessary to process the what and why. + */ +export type PlatformData = { + source: Source; + responseDestination: ResponseDestination; +}; + +export type NTBSInput

= { + /** + * Each NTBSEvent carries the adapter-defined external data. + * T3 never inspects it. Only the adapter deals with it. + */ + platformData: P; + /** + * The captured source text sent as the first T3 user message. + * Platform independent. + * Must not exceed T3's 120,000-character input limit. + */ + snapshot: string; + /** + * References to attachments stored by T3 and sent with the first user message. + * The processor creates them from attachment data provided by the adapter. + */ + attachments: ReadonlyArray; +}; + +export type ThreadEvent

= NTBSInput

& { + t3Data: { + /** The T3 thread created by the lifecycle event */ + threadId: ThreadId; + }; +}; + +export type ThreadStarted

= ThreadEvent

& { + /** T3 has created the new thread. */ + state: "thread.started"; +}; +export type ResponsePosted

= ThreadEvent

& { + state: "thread.response.posted"; + responseMessageId: string; +}; + +export type NTBSLifecycle

= ThreadStarted

| ResponsePosted

; diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 1d405409a2e9..b3e0bf8f9b30 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,5 +1,10 @@ -import { ThreadId, type OrchestrationEvent, type ProjectId } from "@t3tools/contracts"; -import type * as NTBS from "./schemas.ts"; +import { + type ChatAttachment, + type OrchestrationEvent, + type ProjectId, + type ThreadId, +} from "@t3tools/contracts"; +import type * as NTBS from "./lifecycle.ts"; import { Context, Crypto, Data, Effect } from "effect"; import type { NTBSAdapter } from "./adapter.ts"; import type { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; @@ -8,29 +13,29 @@ import type { GitWorkflowService } from "../git/GitWorkflowService.ts"; import type { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; /* - NTBS architectural description: - 1. Generic NTBS processor: - - Contains the shared workflow for every adapter. The business logic, regardless of the actual NTBS is identical - - Makes queries to the specific platform adapter - - Uses private T3-specific effect to create a fresh worktree and T3 thread - - Saves `ThreadStarted` - - Posts the acknowledgment through the adapter and saves `ThreadStartedAcknowledgement` - - Starts the first turn with `snapshot` + NTBS architecture: + 1. Generic NTBS processor: + - Runs the shared workflow for every platform. + - Asks the adapter to detect duplicate input. + - Creates a fresh worktree and T3 thread. + - Saves `ThreadStarted`. + - Starts the first turn with the snapshot and attachments. + - Attempts to post the acknowledgement independently. - Watches T3 events for completed work. - - Finds the adapter record by T3 thread ID, posts the final result - and saves `ResponseAvailable` and `ResponsePosted` + - Posts the final result through the adapter and saves `ResponsePosted`. - 2. Platform handler - - Receives raw platform data (Jira, Discord, Github, Teams) - - Builds `RequestAccepted

and `T3Context` - - Calls the processor + 2. Platform-specific inbound code: + - Receives raw platform data from Jira, Discord, GitHub, or Teams. + - Applies platform trigger and actor checks. + - Builds `NTBSInput

` and `T3Context`. + - Calls the processor. 3. Adapter - - Owns platform storage, duplicate detection and platform API calls - - Knows how to post acknowledgments and responses - - Knows how platform identifiers are represented - - Knows nothing about creating T3 threads or interpreting T3 events + - Owns platform storage, duplicate detection, and platform API calls. + - Posts acknowledgements and responses. + - Knows how platform identifiers are represented. + - Knows nothing about creating T3 threads or interpreting T3 events. */ export type T3Context = { @@ -41,7 +46,7 @@ export type T3Context = { export type ProcessorEvent

= | { readonly source: "adapter"; - readonly event: NTBS.RequestAccepted

; + readonly event: NTBS.NTBSInput

; readonly t3Context: T3Context; } | { @@ -82,7 +87,7 @@ type NTBSProcessorRequirements = | OrchestrationEngineService /* Loads the selected T3 project and reads the completed thread - state and response tex. + state and response text. */ | ProjectionSnapshotQuery /* @@ -113,6 +118,7 @@ declare const createT3Thread: (t3Context: T3Context) => Effect.Effect, ) => Effect.Effect; /** @@ -120,12 +126,12 @@ declare const startT3Turn: ( * * 1. Ask the adapter to accept it and stop if it is a duplicate. * 2. Create the worktree and T3 thread. - * 3. Record `ThreadStarted` - * 4. Post and record the acknowledgement. - * 5. Start the first T3 turn with the source snapshot + * 3. Record `ThreadStarted`. + * 4. Start the first T3 turn with the snapshot and attachments. + * 5. Attempt to post the acknowledgement independently. */ declare const processAcceptedRequest:

( - request: NTBS.RequestAccepted

, + request: NTBS.NTBSInput

, t3Context: T3Context, ) => Effect.Effect; @@ -154,8 +160,8 @@ declare const resolveT3Outcome: ( * 2. Find the adapter record by thread ID. * 3. Stop if no record exists or the response was already posted. * 4. Resolve the T3 outcome and stop if the turn has not ended. - * 5. Record `ResponseAvailable`. - * 6. Post the response and record `ResponsePosted`. + * 5. Post the response. + * 6. Record `ResponsePosted`. */ declare const processT3Event: ( event: OrchestrationEvent, diff --git a/apps/server/src/ntbs/schemas.ts b/apps/server/src/ntbs/schemas.ts deleted file mode 100644 index 3dd3fc4225e5..000000000000 --- a/apps/server/src/ntbs/schemas.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ThreadId } from "@t3tools/contracts"; - -/** - * Describes the platform-specific data of a - * Non-Turn-Based-Surface. - * - * When receiving an NTBS event (a comment, a message tagging - * a bot, etc) `source` and `responseDestination` hold the details - * necessary to process the what and why. - */ -export type PlatformData = { - source: Source; - responseDestination: ResponseDestination; -}; - -export type LifecycleEvent

= { - /** - * Each NTBSEvent carries the adapter-defined external data. - * T3 never inspects it. Only the adapter deals with it. - */ - platformData: P; - /** - * The captured source text used to send the first T3 user message. - * Platform-independent. - */ - snapshot: string; -}; - -export type ThreadEvent

= LifecycleEvent

& { - t3Data: { - /** The T3 thread created by the lifecycle event */ - threadId: ThreadId; - }; -}; - -export type RequestAccepted

= LifecycleEvent

& { - state: "request.accepted"; -}; - -export type ThreadStarted

= ThreadEvent

& { - /** T3 has created the new thread. */ - state: "thread.started"; -}; - -export type ThreadStartedAcknowledgement

= ThreadEvent

& { - state: "thread.started.acknowledged"; - /** the external's platform identification of the acknowledgment message */ - acknowledgementMessageId: string; -}; - -export type ResponseAvailable

= ThreadEvent

& { - state: "thread.response.available"; - /** the external's platform identification of the acknowledgment message */ - acknowledgementMessageId: string; -}; - -export type ResponsePosted

= ThreadEvent

& { - state: "thread.response.posted"; - /** the external's platform identification of the acknowledgment message */ - acknowledgementMessageId: string; - responseMessageId: string; -}; - -export type NTBSLifecycle

= - | RequestAccepted

- | ThreadStarted

- | ThreadStartedAcknowledgement

- | ResponseAvailable

- | ResponsePosted

; diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md index 1abfb70ccd2e..0a6e439112d7 100644 --- a/docs/planning/fixes.md +++ b/docs/planning/fixes.md @@ -2,28 +2,6 @@ This document collects the units of work identified by the adversarial review of the NTBS design. -## 1. Defer snapshot retention to adapter implementation - -An adapter stores external content in `snapshot`, and that copy may remain after the original comment or message is deleted from its platform. - -Document this as a data-retention concern. Retention periods, storage limits, and handling source deletions are adapter implementation and deployment choices, so do not define them in the shared processor lifecycle yet. - -## 2. Keep fork-specific provenance out of NTBS - -`SourceChannel`, `SourceRef`, `sourceHint`, `originSource`, and the related identity behavior were added by this fork. They are not part of upstream T3 and must not become requirements of the generic NTBS processor. - -Do not add these fields to `T3Context` or the NTBS lifecycle. The adapter already retains the platform data needed to connect an external message with its T3 work. Any integration with the fork's separate identity features can be handled outside the shared NTBS machinery. - -## 3. Document the snapshot limit and review attachments - -Document on the `snapshot` field that it is used as the first T3 message and must fit T3's current 120,000-character input limit. Do not add truncation behavior yet. Review attachment support separately before implementation so that keeping `snapshot` text-only is an explicit decision rather than an accidental limitation. - -## 4. Make lifecycle naming consistent - -The current skeleton mixes schemas, events, records, and states when referring to the same stored lifecycle data. This makes the small lifecycle harder to understand and leaves the code and planning documents using different names. - -Rename `schemas.ts` to `lifecycle.ts`, because it contains TypeScript lifecycle types rather than Effect schemas. Use lifecycle-state terminology consistently in the file, comments, and dependent APIs. Align the code and documentation with the remaining `ThreadStarted` and `ResponsePosted` states, and fix the existing comment typos. Keep the established `NTBS` acronym casing. - ## 5. Preserve platform actor checks in the adapters The current Jira and GitHub integrations check whether the external account is allowed to start agent work, but the NTBS skeleton does not mention this behavior. Without carrying it into the new adapters, porting those integrations would silently remove an existing check. @@ -42,30 +20,6 @@ The processor currently reduces every T3 outcome to plain text before calling th Define a small response union with `answer`, `failure`, `timeout`, and `cancellation` cases, each carrying its response text. The processor determines which case occurred and passes it to `postResponse`; the adapter decides how that case is rendered on its platform. This response union is not an additional persisted lifecycle state. -## 8. Remove `ResponseAvailable` from the lifecycle - -`ResponseAvailable` does not currently enable a distinct recovery action because it stores no response. Recovery must still inspect T3 to determine the outcome, so persisting this extra transition adds little value. - -Keep the durable lifecycle limited to `ThreadStarted` and `ResponsePosted`. While a record remains `ThreadStarted`, the processor checks T3 and continues waiting or attempts to post the resolved outcome. After posting succeeds, it saves `ResponsePosted`. - -Add a durable pending-response state later only if real delivery retries require storing the exact outbound outcome independently from T3. - -## 9. Make acknowledgements independent from the shared lifecycle - -The acknowledgement is a platform message such as "working on it." It improves feedback for the user, but the current types make its message ID mandatory for `ResponseAvailable` and `ResponsePosted`. If posting the acknowledgement fails, the processor cannot represent or post the final response even though T3 work can continue. - -After creating the T3 thread, the processor records `ThreadStarted`. Starting the T3 work and attempting to post the acknowledgement are then independent operations. A failed acknowledgement must not prevent the work from starting, completing, or returning its final response. - -Remove `ThreadStartedAcknowledgement` from the shared lifecycle and remove `acknowledgementMessageId` from later lifecycle states. An adapter may retain the acknowledgement ID in its own storage and retry posting when appropriate, but final-response processing must not depend on it. - -## 10. Remove the pre-thread lifecycle state - -`RequestAccepted` exists to recover a request when the server stops before recording `ThreadStarted`. Supporting that narrow failure window requires planned thread IDs, searches for unfinished requests, startup retries, and rules for resuming duplicates. - -Do not add that machinery in the first implementation. Remove `RequestAccepted` and make `ThreadStarted` the first stored lifecycle state. Record it as soon as the basic T3 thread exists, before slower worktree preparation or project setup begins. - -This deliberately accepts one limitation: if the server stops before `ThreadStarted` is saved, the request may be lost. The user receives no acknowledgement and can send the request again. If this becomes a real problem, each adapter can later inspect recent platform messages and recover missing requests using the capabilities of that platform. - ## 11. Define the shared thread defaults T3 requires an initial title, model selection, runtime mode, and interaction mode when creating a thread. `T3Context` currently provides only the project and revision, so the implementation would otherwise have to invent these choices or make each platform choose them independently. diff --git a/docs/planning/ideas.md b/docs/planning/ideas.md index 4edd50d3f449..8b4bbdda2e6c 100644 --- a/docs/planning/ideas.md +++ b/docs/planning/ideas.md @@ -19,3 +19,9 @@ The shared sequence becomes: `Create the T3 thread → record ThreadStarted → start the work and attempt the acknowledgement independently` The processor does not use acknowledgement success as a condition for continuing. Posting may happen alongside the start of T3 work, and an adapter may retry a failed acknowledgement, but the final answer always remains tied to the original response destination. If a platform benefits from replying to the acknowledgement, its adapter can use the identifier stored in its own data without adding that dependency to the shared lifecycle. + +## Remove fork-specific provenance after the NTBS migration + +Keep `SourceChannel`, `SourceRef`, `sourceHint`, `originSource`, and related fork-specific provenance out of the NTBS design. Adapters already retain the platform data needed to connect external messages with T3 work. + +Once every external platform has moved to NTBS, remove these fields and the old integration logic that depends on them. From 18f012bdd22cc9e8365e336b751c4b66e7708aad Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 17:23:45 +0200 Subject: [PATCH 034/110] feat: more ntbs fixes --- apps/server/src/ntbs/platform-handler.ts | 26 ------------------------ apps/server/src/ntbs/processor.ts | 5 ++++- docs/planning/fixes.md | 12 ----------- 3 files changed, 4 insertions(+), 39 deletions(-) delete mode 100644 apps/server/src/ntbs/platform-handler.ts diff --git a/apps/server/src/ntbs/platform-handler.ts b/apps/server/src/ntbs/platform-handler.ts deleted file mode 100644 index 8206baf3108e..000000000000 --- a/apps/server/src/ntbs/platform-handler.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Context, Data, Effect } from "effect"; - -export class NTBSPlatformHandlerError extends Data.TaggedError("NTBSPlatformHandlerError")<{ - reason: string; -}> {} - -/** - * Connects a platform's incoming messages or comments to shared NTBS processor. - * - * It determines whether the input should start work. If so, it captures the platform data, - * source snapshot, and T3 context, then passes them to the processor. - * - * Duplicate detection, lifecycle storage, and platform API calls belong to the adapter. - */ -export interface NTBSPlatformHandler { - readonly handle: (input: Input) => Effect.Effect; -} - -/** - * Creates the Effect service tag used to provide and access one platform handler. - * - * This identifies the handler in the Effect context. - * It does not create the handler implementation. - */ -export const makeNTBSPlatformHandlerTag = (key: string) => - Context.Service>(key); diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index b3e0bf8f9b30..f42993e5b70e 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -62,7 +62,10 @@ export interface NTBSProcessor

{ /** * Routes adapter requests and T3 events through the shared NTBS workflow. * - * `process` accepts concurrent requests and applies no queue, concurrency cap + * Platform requests must already have passed their platform-specific trigger + * and actor checks. The processor does not perform those. + * + * Accepts concurrent requests and applies no queue, concurrency cap * or backpressure for the time being. This choice can be reviewed later. */ readonly process: (event: ProcessorEvent

) => Effect.Effect; diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md index 0a6e439112d7..18a0fbf164e3 100644 --- a/docs/planning/fixes.md +++ b/docs/planning/fixes.md @@ -2,18 +2,6 @@ This document collects the units of work identified by the adversarial review of the NTBS design. -## 5. Preserve platform actor checks in the adapters - -The current Jira and GitHub integrations check whether the external account is allowed to start agent work, but the NTBS skeleton does not mention this behavior. Without carrying it into the new adapters, porting those integrations would silently remove an existing check. - -Each adapter must apply its platform-specific actor checks before sending a request to the shared processor. Input that fails those checks does not enter the NTBS lifecycle. The shared processor does not need an actor-trust model or any additional trust data. - -## 6. Remove the shared platform-handler abstraction - -`NTBSPlatformHandler` only states that a platform has a `handle` function. Each platform receives a different input type, and no shared code uses these handlers interchangeably, so the interface and its Effect tag add no shared behavior. - -Delete `platform-handler.ts`. Each platform adapter instead exposes its own concrete inbound function, such as a Jira webhook handler or Discord message handler. That function verifies and parses the platform input, applies its trigger and actor checks, builds the generic NTBS request and `T3Context`, and calls the shared processor. Keep `NTBSAdapter` limited to the storage and outbound operations used by the processor. - ## 7. Pass a response union to the adapter The processor currently reduces every T3 outcome to plain text before calling the adapter. The adapter therefore cannot distinguish a normal answer from a failure, timeout, or cancellation when applying its platform-specific rendering. From bf9662ca544801919c13b1923a50b93f4431ca32 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 17:47:53 +0200 Subject: [PATCH 035/110] fix #7 of adversarial review --- apps/server/src/ntbs/adapter.ts | 7 ++++++- apps/server/src/ntbs/processor.ts | 6 +++--- docs/planning/fixes.md | 6 ------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 5b89d6c42653..70b3d2fa1448 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -11,6 +11,11 @@ export class AdapterError extends Data.TaggedError("AdapterError")<{ readonly reason: string; }> {} +export type NTBSResponse = { + readonly type: "answer" | "failure" | "timeout" | "cancellation"; + readonly text: string; +}; + /** * Defines the platform-specific operations used by the shared NTBS processor. * @@ -55,7 +60,7 @@ export interface NTBSAdapter

{ */ readonly postResponse: ( state: NTBS.ThreadStarted

, - text: string, + response: NTBSResponse, ) => Effect.Effect; /** * Finds the latest lifecycle state associated with a T3 thread. diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index f42993e5b70e..f9e16769f764 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -6,7 +6,7 @@ import { } from "@t3tools/contracts"; import type * as NTBS from "./lifecycle.ts"; import { Context, Crypto, Data, Effect } from "effect"; -import type { NTBSAdapter } from "./adapter.ts"; +import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; import type { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import type { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import type { GitWorkflowService } from "../git/GitWorkflowService.ts"; @@ -147,12 +147,12 @@ declare const processAcceptedRequest:

( * * This function reads the projected thread identified by the session event. * It returns `null` if the latest turn has not ended. - * Otherwise it returns the final assistant text or plain text error. + * Otherwise it returns the response and its type. */ declare const resolveT3Outcome: ( event: Extract, ) => Effect.Effect< - { readonly threadId: ThreadId; readonly text: string } | null, + { readonly threadId: ThreadId; readonly response: NTBSResponse } | null, NTBSProcessorError >; diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md index 18a0fbf164e3..db893be916fa 100644 --- a/docs/planning/fixes.md +++ b/docs/planning/fixes.md @@ -2,12 +2,6 @@ This document collects the units of work identified by the adversarial review of the NTBS design. -## 7. Pass a response union to the adapter - -The processor currently reduces every T3 outcome to plain text before calling the adapter. The adapter therefore cannot distinguish a normal answer from a failure, timeout, or cancellation when applying its platform-specific rendering. - -Define a small response union with `answer`, `failure`, `timeout`, and `cancellation` cases, each carrying its response text. The processor determines which case occurred and passes it to `postResponse`; the adapter decides how that case is rendered on its platform. This response union is not an additional persisted lifecycle state. - ## 11. Define the shared thread defaults T3 requires an initial title, model selection, runtime mode, and interaction mode when creating a thread. `T3Context` currently provides only the project and revision, so the implementation would otherwise have to invent these choices or make each platform choose them independently. From 84a5da4c3273069a49844ef2b9bc510315306ac3 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 20:54:00 +0200 Subject: [PATCH 036/110] feat: add or note all fixes --- apps/server/src/ntbs/adapter.ts | 19 +++++++++ apps/server/src/ntbs/lifecycle.ts | 9 ++++- apps/server/src/ntbs/processor.ts | 64 +++++++++++++++++++++++++----- docs/planning/fixes.md | 66 ------------------------------- docs/planning/ideas.md | 6 +++ 5 files changed, 87 insertions(+), 77 deletions(-) delete mode 100644 docs/planning/fixes.md diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 70b3d2fa1448..9976ab20a9c8 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -62,6 +62,17 @@ export interface NTBSAdapter

{ state: NTBS.ThreadStarted

, response: NTBSResponse, ) => Effect.Effect; + /** + * Searches the response destination for a matching response previously + * posted by this adapter. + * + * Returns the platform message ID when found, or `null` when no matching + * message exists. + */ + readonly findMatchingResponseMessage: ( + state: NTBS.ThreadStarted

, + response: NTBSResponse, + ) => Effect.Effect; /** * Finds the latest lifecycle state associated with a T3 thread. * @@ -71,6 +82,14 @@ export interface NTBSAdapter

{ readonly findByThreadId: ( threadId: ThreadId, ) => Effect.Effect, ThreadNotFound | AdapterError>; + /** + * Loads records that reached `ThreadStarted` but have no recorded + * `ResponsePosted` state. + */ + readonly loadThreadsAwaitingResponse: Effect.Effect< + ReadonlyArray>, + AdapterError + >; } export const makeNTBSAdapterTag =

(key: string) => diff --git a/apps/server/src/ntbs/lifecycle.ts b/apps/server/src/ntbs/lifecycle.ts index 49aaf86d5c23..1fc1c5d9cd56 100644 --- a/apps/server/src/ntbs/lifecycle.ts +++ b/apps/server/src/ntbs/lifecycle.ts @@ -1,4 +1,4 @@ -import type { ChatAttachment, ThreadId } from "@t3tools/contracts"; +import type { ChatAttachment, MessageId, ThreadId } from "@t3tools/contracts"; /** * Describes the platform-specific data of a @@ -36,6 +36,12 @@ export type ThreadEvent

= NTBSInput

& { t3Data: { /** The T3 thread created by the lifecycle event */ threadId: ThreadId; + /** + * The first T3 user message created for this external request. + * This identifies the correct turn and response even if the thread later + * receives other messages. + */ + userMessageId: MessageId; }; }; @@ -43,6 +49,7 @@ export type ThreadStarted

= ThreadEvent

& { /** T3 has created the new thread. */ state: "thread.started"; }; + export type ResponsePosted

= ThreadEvent

& { state: "thread.response.posted"; responseMessageId: string; diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index f9e16769f764..2677d6bebbcc 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,5 +1,6 @@ import { type ChatAttachment, + type MessageId, type OrchestrationEvent, type ProjectId, type ThreadId, @@ -21,9 +22,11 @@ import type { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunn - Creates a fresh worktree and T3 thread. - Saves `ThreadStarted`. - Starts the first turn with the snapshot and attachments. + - Monitors the turn for completion and timeouts. - Attempts to post the acknowledgement independently. - Watches T3 events for completed work. - Posts the final result through the adapter and saves `ResponsePosted`. + - Archives the T3 thread after its response has been recorded. 2. Platform-specific inbound code: - Receives raw platform data from Jira, Discord, GitHub, or Teams. @@ -73,6 +76,9 @@ export interface NTBSProcessor

{ /** * Consumes T3 events and passes them to `processT3Event`. * + * After the live subscription begins, loads stored `ThreadStarted` records + * and restarts their monitors from each turn's original `requestedAt` time. + * * Runs until interrupted by its caller. * Logs individual processing failures and continues with later events. */ @@ -89,8 +95,7 @@ type NTBSProcessorRequirements = */ | OrchestrationEngineService /* - Loads the selected T3 project and reads the completed thread - state and response text. + Loads the selected T3 project and reads thread outcomes and archive state. */ | ProjectionSnapshotQuery /* @@ -109,6 +114,10 @@ type NTBSProcessorRequirements = /** * Creates an isolated worktree and a new T3 thread. * + * Uses the supplied project and revision. The thread starts with T3's default + * title, the project's default model or T3's fallback model, `full-access` + * runtime mode, and `default` interaction mode. + * * Does not start a turn, read platform data or call the adapter. * * The final title of the thread is generated by T3 after the first turn starts. @@ -117,21 +126,42 @@ declare const createT3Thread: (t3Context: T3Context) => Effect.Effect, ) => Effect.Effect; +/** + * Monitors one started T3 turn without blocking request processing. + * + * Starts in the background immediately after `startT3Turn` succeeds. It checks + * the turn 30 minutes after its original `requestedAt` time, then checks again + * at 45 minutes if it is still running. Startup recovery restarts this monitor + * from the original `requestedAt` time rather than resetting the deadline. + * + * If the turn is still running after 45 minutes, interrupts it, waits for T3 to + * confirm it stopped, posts a timeout response, and records `ResponsePosted`. + * The timed-out thread remains unarchived for inspection or manual retry. + */ +declare const monitorT3Turn:

( + state: NTBS.ThreadStarted

, +) => Effect.Effect; + /** * Handles an external request in this order: * * 1. Ask the adapter to accept it and stop if it is a duplicate. * 2. Create the worktree and T3 thread. - * 3. Record `ThreadStarted`. - * 4. Start the first T3 turn with the snapshot and attachments. - * 5. Attempt to post the acknowledgement independently. + * 3. Generate the first user message ID and record it with `ThreadStarted`. + * 4. Start the first T3 turn with that message ID, the snapshot, and attachments. + * 5. Start monitoring the turn in the background. + * 6. Attempt to post the acknowledgement independently. */ declare const processAcceptedRequest:

( request: NTBS.NTBSInput

, @@ -146,25 +176,39 @@ declare const processAcceptedRequest:

( * External NTBS adapters do not consume T3 projections automatically, so they must read the thread state themselves. * * This function reads the projected thread identified by the session event. - * It returns `null` if the latest turn has not ended. + * It finds the recorded user message, then resolves the response from that + * message's turn rather than whichever turn happens to be latest. + * It returns `null` if that turn has not ended. * Otherwise it returns the response and its type. */ declare const resolveT3Outcome: ( event: Extract, + userMessageId: MessageId, ) => Effect.Effect< { readonly threadId: ThreadId; readonly response: NTBSResponse } | null, NTBSProcessorError >; +/** + * Archives a T3 thread after its external response has been recorded. + * + * Returns successfully when the thread is already archived. A failure can be + * retried without posting the external response again. + * Timed-out threads are left unarchived for inspection or manual retry. + */ +declare const archiveT3Thread: (threadId: ThreadId) => Effect.Effect; + /** * Handles T3 events that may indicate that a turn has ended. * * 1. Ignore events other than `thread.session-set`. * 2. Find the adapter record by thread ID. - * 3. Stop if no record exists or the response was already posted. - * 4. Resolve the T3 outcome and stop if the turn has not ended. - * 5. Post the response. - * 6. Record `ResponsePosted`. + * 3. Stop if no record exists. + * 4. Stop if the response was already posted. + * 5. Resolve the T3 outcome and stop if the turn has not ended. + * 6. Post the response. + * 7. Record `ResponsePosted`. + * 8. Archive the T3 thread. */ declare const processT3Event: ( event: OrchestrationEvent, diff --git a/docs/planning/fixes.md b/docs/planning/fixes.md deleted file mode 100644 index db893be916fa..000000000000 --- a/docs/planning/fixes.md +++ /dev/null @@ -1,66 +0,0 @@ -# NTBS fixes - -This document collects the units of work identified by the adversarial review of the NTBS design. - -## 11. Define the shared thread defaults - -T3 requires an initial title, model selection, runtime mode, and interaction mode when creating a thread. `T3Context` currently provides only the project and revision, so the implementation would otherwise have to invent these choices or make each platform choose them independently. - -Keep `T3Context` limited to `projectId` and `revision`. The shared processor applies the same policy for every platform: - -- Create the thread with T3's default title and let the normal first-turn title generation replace it. -- Use the project's default model selection, falling back to T3's automatic bootstrap model selection. -- Use `full-access` runtime mode. -- Use `default` interaction mode. -- Treat `revision` as the Git ref from which the new isolated worktree starts, and resolve it when creating that worktree. - -Adapters provide the project and revision but do not choose or persist the remaining thread settings in the first implementation. - -## 12. Resolve the response for the correct T3 message - -The processor currently stores only the T3 thread ID and reads the latest turn when looking for the final response. If someone continues that thread from a native T3 client, the latest turn may belong to different work and its answer could be posted back to the original external request. - -Store the ID of the first T3 user message with `ThreadStarted`. Resolve the final response for that specific message instead of reading the latest turn in the thread. Record the corresponding turn ID later when T3 provides it. - -## 13. Archive the T3 thread after posting the response - -Every external request creates a new T3 thread and worktree. Leaving them open after the response has been posted would cause unused threads and worktrees to accumulate. - -After the response has been successfully posted and saved as `ResponsePosted`, archive its T3 thread so the normal T3 cleanup rules can remove the worktree. Treat archival as separate cleanup: if it fails, retry the archival without posting the response again. This can become a configurable retention policy later if a platform needs different behavior. - -## 14. Keep remote adapters in mind - -The current NTBS shape assumes adapters run inside the T3 server and call the processor directly. This works for the initial Jira and GitHub adapters, but the current Discord bot runs as a separate program. - -When Discord is ported, either move its adapter into the server or expose the processor through a network API. Do not choose or implement that transport yet, but avoid making the lifecycle and storage design depend unnecessarily on every adapter sharing the server process. - -## 15. Verify an uncertain response before posting it again - -The adapter may successfully post a response and then stop before saving `ResponsePosted`. On recovery, it must not immediately post the response again. - -First inspect recent messages in the known response destination. Look for a message authored by the adapter, posted in the expected time frame, attached to the expected comment or thread, and containing the expected response. If it is found, save `ResponsePosted` using the existing platform message and continue without posting again. Retry `postResponse` only when that check finds no matching message. - -Each adapter owns the exact comparison because platforms may format, truncate, or split messages differently. - -## 16. Recover missed T3 events after restart - -The processor only receives T3 events emitted while it is running. If T3 finishes work while the processor or server is down, the completion event is missed and the response would never be posted. - -Whenever the processor starts, load every unfinished NTBS record and check its current state in T3. Continue completed work, report failures, and resume waiting for work that is still running. Treat live T3 events as signals to check the current state, not as the only record of what happened. - -This recovery does not require storing an event cursor or replaying the T3 event log. T3's current state and the adapter's unfinished records provide enough information to continue. - -## 17. Handle work that does not finish in time - -The processor currently waits forever for T3 to report that a turn has finished. If the provider hangs or disconnects without producing a completion event, the external platform never receives a final message. - -Check the target turn after 30 minutes: - -- If it is `running` or `starting`, wait another 15 minutes. -- If it is `completed`, retrieve and post its answer. -- If it is in `error`, `interrupted`, or `stopped`, post that failure without automatically rerunning the agent. -- If no turn ever started, retry the turn-start command once when it is safe to repeat. -- If the thread cannot be read because of a temporary failure, retry the status check rather than the T3 work. -- If the thread no longer exists, post an error and stop. - -If the turn is still running after 45 minutes, post that T3 is still working and include a link to the T3 thread. Then close the external response flow. A later answer remains available in T3 but is not posted automatically to the external platform. diff --git a/docs/planning/ideas.md b/docs/planning/ideas.md index 8b4bbdda2e6c..2790f25dc530 100644 --- a/docs/planning/ideas.md +++ b/docs/planning/ideas.md @@ -25,3 +25,9 @@ The processor does not use acknowledgement success as a condition for continuing Keep `SourceChannel`, `SourceRef`, `sourceHint`, `originSource`, and related fork-specific provenance out of the NTBS design. Adapters already retain the platform data needed to connect external messages with T3 work. Once every external platform has moved to NTBS, remove these fields and the old integration logic that depends on them. + +## Keep remote adapters possible + +The first NTBS adapters can run inside the T3 server, but some platform integrations may remain separate programs. The current Discord bot is one example. + +When a remote adapter is implemented, either move its platform operations into the server or expose the processor and adapter operations through a network API. The shared lifecycle and storage design should not require every adapter to share the T3 server process. Choose the transport when the first remote adapter is ported. From 4b2a545bb5db1f229b787198ec93b07c94ebfc74 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 22:21:42 +0200 Subject: [PATCH 037/110] feat: start working on processor --- apps/server/src/ntbs/processor.ts | 41 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 2677d6bebbcc..3cb43342370c 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -46,32 +46,24 @@ export type T3Context = { readonly revision: string; }; -export type ProcessorEvent

= - | { - readonly source: "adapter"; - readonly event: NTBS.NTBSInput

; - readonly t3Context: T3Context; - } - | { - readonly source: "t3"; - readonly event: OrchestrationEvent; - }; - export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ reason: string; }> {} export interface NTBSProcessor

{ /** - * Routes adapter requests and T3 events through the shared NTBS workflow. + * Processes a request received by a platform adapter. * - * Platform requests must already have passed their platform-specific trigger - * and actor checks. The processor does not perform those. + * The request must already have passed its platform-specific trigger and actor + * checks. The processor does not perform those. * * Accepts concurrent requests and applies no queue, concurrency cap * or backpressure for the time being. This choice can be reviewed later. */ - readonly process: (event: ProcessorEvent

) => Effect.Effect; + readonly process: ( + request: NTBS.NTBSInput

, + t3Context: T3Context, + ) => Effect.Effect; /** * Consumes T3 events and passes them to `processT3Event`. @@ -163,7 +155,7 @@ declare const monitorT3Turn:

( * 5. Start monitoring the turn in the background. * 6. Attempt to post the acknowledgement independently. */ -declare const processAcceptedRequest:

( +declare const processAdapterRequest:

( request: NTBS.NTBSInput

, t3Context: T3Context, ) => Effect.Effect; @@ -219,6 +211,19 @@ declare const processT3Event: ( * * Resolves the required T3 services and returns processor operations with no remaining requirements. */ -export declare const makeNTBSProcessor:

( +export const makeNTBSProcessor =

( adapter: NTBSAdapter

, -) => Effect.Effect, never, NTBSProcessorRequirements>; +): Effect.Effect, never, NTBSProcessorRequirements> => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + + const process = (request: NTBS.NTBSInput

, t3Context: T3Context) => + processAdapterRequest(request, t3Context); + + const subscribeToT3Events = Effect.void; + + return { + process, + subscribeToT3Events, + }; + }); From 5b35c02d70ef2a07c9485fef3c6ed70e5e715c84 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 9 Aug 2026 22:54:49 +0200 Subject: [PATCH 038/110] chore: remove adapter.accept, not really needed --- apps/server/src/ntbs/adapter.ts | 8 -------- apps/server/src/ntbs/processor.ts | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 9976ab20a9c8..69d72163154d 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -30,14 +30,6 @@ export type NTBSResponse = { * It does not create T3 threads or interpret T3 events. */ export interface NTBSAdapter

{ - /** - * Stores the request before any T3 work begins. - * - * Returns `"duplicate"` if the same platform request was already stored. - */ - readonly accept: ( - event: NTBS.NTBSInput

, - ) => Effect.Effect<"accepted" | "duplicate", AdapterError>; /** * Stores a lifecycle state. Does not perform any other business logic. */ diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 3cb43342370c..8cbef0838756 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -217,6 +217,22 @@ export const makeNTBSProcessor =

( Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + /* + Handles an external request in this order: + + Ask the adapter to accept it and stop if it is a duplicate. + Create the worktree and T3 thread. + Generate the first user message ID and record it with ThreadStarted. + Start the first T3 turn with that message ID, the snapshot, and attachments. + Start monitoring the turn in the background. + Attempt to post the acknowledgement independently. + */ + + const processAdapterRequest = (request: NTBS.NTBSInput

, t3Context: T3Context) => + Effect.gen(function* () { + return yield* Effect.void; + }); + const process = (request: NTBS.NTBSInput

, t3Context: T3Context) => processAdapterRequest(request, t3Context); From 69ff9044ff0dc71d44498b1203888a1258642853 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 10 Aug 2026 01:58:41 +0200 Subject: [PATCH 039/110] chore: continue work on processor --- apps/server/src/ntbs/adapter.ts | 19 ++++++- apps/server/src/ntbs/processor.ts | 86 +++++++++++++++++-------------- 2 files changed, 64 insertions(+), 41 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 69d72163154d..e4b0bd4d55ec 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -19,8 +19,8 @@ export type NTBSResponse = { /** * Defines the platform-specific operations used by the shared NTBS processor. * - * The adapter detects duplicate requests, stores lifecycle data, finds that data - * from a T3 thread ID, and posts acknowledgements and responses. + * The adapter stores lifecycle data, finds that data from a T3 thread ID, and + * posts acknowledgements and responses. * * The adapter owns its storage and retention policy. A stored snapshot may * outlive the original platform message. E.g. a message on Discord gets deleted @@ -54,6 +54,21 @@ export interface NTBSAdapter

{ state: NTBS.ThreadStarted

, response: NTBSResponse, ) => Effect.Effect; + + /** + * Finds lifecycle data already recorded for this platform + request. + * + * The adapter identifies the request using its platform- + specific source data. + * Returns `null` when no T3 thread has been recorded and + processing may continue. + * Any lifecycle state means the request has already started + T3 work. + */ + readonly findByRequest: ( + request: NTBS.NTBSInput

, + ) => Effect.Effect | null, AdapterError>; /** * Searches the response destination for a matching response previously * posted by this adapter. diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 8cbef0838756..87b79d7e9efe 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -3,7 +3,7 @@ import { type MessageId, type OrchestrationEvent, type ProjectId, - type ThreadId, + ThreadId, } from "@t3tools/contracts"; import type * as NTBS from "./lifecycle.ts"; import { Context, Crypto, Data, Effect } from "effect"; @@ -18,7 +18,6 @@ import type { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunn 1. Generic NTBS processor: - Runs the shared workflow for every platform. - - Asks the adapter to detect duplicate input. - Creates a fresh worktree and T3 thread. - Saves `ThreadStarted`. - Starts the first turn with the snapshot and attachments. @@ -35,7 +34,7 @@ import type { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunn - Calls the processor. 3. Adapter - - Owns platform storage, duplicate detection, and platform API calls. + - Owns platform storage and platform API calls. - Posts acknowledgements and responses. - Knows how platform identifiers are represented. - Knows nothing about creating T3 threads or interpreting T3 events. @@ -91,7 +90,7 @@ type NTBSProcessorRequirements = */ | ProjectionSnapshotQuery /* - Creates the isolated branch and worktree for each accepted external request. + Creates the isolated branch and worktree for each external request. */ | GitWorkflowService /* @@ -103,19 +102,6 @@ type NTBSProcessorRequirements = */ | Crypto.Crypto; -/** - * Creates an isolated worktree and a new T3 thread. - * - * Uses the supplied project and revision. The thread starts with T3's default - * title, the project's default model or T3's fallback model, `full-access` - * runtime mode, and `default` interaction mode. - * - * Does not start a turn, read platform data or call the adapter. - * - * The final title of the thread is generated by T3 after the first turn starts. - */ -declare const createT3Thread: (t3Context: T3Context) => Effect.Effect; - /** * Starts the first turn in an existing T3 thread. * @@ -145,21 +131,6 @@ declare const monitorT3Turn:

( state: NTBS.ThreadStarted

, ) => Effect.Effect; -/** - * Handles an external request in this order: - * - * 1. Ask the adapter to accept it and stop if it is a duplicate. - * 2. Create the worktree and T3 thread. - * 3. Generate the first user message ID and record it with `ThreadStarted`. - * 4. Start the first T3 turn with that message ID, the snapshot, and attachments. - * 5. Start monitoring the turn in the background. - * 6. Attempt to post the acknowledgement independently. - */ -declare const processAdapterRequest:

( - request: NTBS.NTBSInput

, - t3Context: T3Context, -) => Effect.Effect; - /** * Provider runtimes (like Claude Code) emit `turn.completed` events. * T3 consumes those internally and exposes the resulting session change through a `thread.session-set` event. @@ -217,20 +188,57 @@ export const makeNTBSProcessor =

( Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + /** + * Creates an isolated worktree and a new T3 thread. + * + * Uses the supplied project and revision. The thread starts with T3's default + * title, the project's default model or T3's fallback model, `full-access` + * runtime mode, and `default` interaction mode. + * + * Does not start a turn, read platform data or call the adapter. + * + * The final title of the thread is generated by T3 after the first turn starts. + */ + const createT3Thread = (t3Context: T3Context): Effect.Effect => + Effect.sync(function () { + // TODO: Continue from here + const threadId = ThreadId.make("somethread"); + return threadId; + }); + /* Handles an external request in this order: - Ask the adapter to accept it and stop if it is a duplicate. - Create the worktree and T3 thread. - Generate the first user message ID and record it with ThreadStarted. - Start the first T3 turn with that message ID, the snapshot, and attachments. - Start monitoring the turn in the background. - Attempt to post the acknowledgement independently. + 1. Ask the adapter whether this platform request already has a recorded + `ThreadStarted` or `ResponsePosted`. + If yes - stop. . If no - continue + 2. Create the worktree and T3 thread. + 3. Generate the first user message ID and record it with ThreadStarted. + 4. Start the first T3 turn with that message ID, the snapshot, and attachments. + 5. Start monitoring the turn in the background. + 6. Attempt to post the acknowledgement independently. */ const processAdapterRequest = (request: NTBS.NTBSInput

, t3Context: T3Context) => Effect.gen(function* () { - return yield* Effect.void; + const existingRequest = yield* adapter.findByRequest(request).pipe( + Effect.mapError( + () => + new NTBSProcessorError({ + reason: "Error getting the existing request in processAdapterRequest", + }), + ), + ); + if (existingRequest) { + return Effect.void; + } else { + // create the worktree and T3 thread + // generate the first user message ID and record it with ThreadStarted + // Start the first T3 turn with that message Id, the snapshot and attachments + // start monitoring the turn in the background (TODO: aren't we already subscribing for this?) + // Attempt to post the acknowledgement independently + } + return Effect.void; }); const process = (request: NTBS.NTBSInput

, t3Context: T3Context) => From 264eac77570bf124c5646394118d799ad04ddc57 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 10 Aug 2026 16:19:25 +0200 Subject: [PATCH 040/110] feat: processor, implement adversarial review feedback --- apps/server/src/ntbs/adapter.ts | 14 +- apps/server/src/ntbs/processor.ts | 236 ++++++++++++++++-- .../create-thread.adversarial-review.md | 60 +++++ docs/planning/ideas.md | 29 +++ docs/planning/ntbs-architecture.md | 4 +- 5 files changed, 313 insertions(+), 30 deletions(-) create mode 100644 docs/planning/create-thread.adversarial-review.md diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index e4b0bd4d55ec..f033496f1ef2 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -56,15 +56,13 @@ export interface NTBSAdapter

{ ) => Effect.Effect; /** - * Finds lifecycle data already recorded for this platform - request. + * Finds lifecycle data already recorded for this platform request. * - * The adapter identifies the request using its platform- - specific source data. - * Returns `null` when no T3 thread has been recorded and - processing may continue. - * Any lifecycle state means the request has already started - T3 work. + * The adapter identifies the request using its platform-specific + * source data. + * Returns `null` when no T3 thread has been recorded and processing + * may continue. + * Any lifecycle state means the request has already started T3 work. */ readonly findByRequest: ( request: NTBS.NTBSInput

, diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 87b79d7e9efe..5e19d1fa5d80 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,17 +1,24 @@ import { type ChatAttachment, + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, type MessageId, + OrchestrationCommand, type OrchestrationEvent, type ProjectId, ThreadId, } from "@t3tools/contracts"; import type * as NTBS from "./lifecycle.ts"; -import { Context, Crypto, Data, Effect } from "effect"; +import { Context, Crypto, Data, DateTime, Effect } from "effect"; import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; -import type { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; -import type { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import type { GitWorkflowService } from "../git/GitWorkflowService.ts"; -import type { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; +import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { setInputType } from "effect/Schedule"; /* NTBS architecture: @@ -42,11 +49,23 @@ import type { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunn export type T3Context = { readonly projectId: ProjectId; - readonly revision: string; + /** + * The starting point for the thread's worktree: the new branch is created + * from this ref. + * + * Usually a branch name such as `main`. Before use it is resolved against + * `origin`, so the worktree starts from the latest remote commit even when + * the local copy of the branch is behind. A commit SHA is also accepted and + * is used as-is. + * + * Set by the platform-specific inbound code. + */ + readonly baseRef: string; }; export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ reason: string; + cause: unknown; }> {} export interface NTBSProcessor

{ @@ -186,12 +205,84 @@ export const makeNTBSProcessor =

( adapter: NTBSAdapter

, ): Effect.Effect, never, NTBSProcessorRequirements> => Effect.gen(function* () { + const orFail = (reason: string) => + Effect.mapError((cause: unknown) => new NTBSProcessorError({ reason, cause })); + const crypto = yield* Crypto.Crypto; + const randomUUID = crypto.randomUUIDv4.pipe(orFail("Failed creating a UUID v4")); + + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const gitWorkflowService = yield* GitWorkflowService; + + /** + * Resolves where a new thread worktree starts from. + * + * Fetches `origin` and prefers the remote state of `baseRef`, so a branch name resolves to its latest remote commit even when the local copy is behind. + * + * When no remote branch with that name exists + * (a commit SHA, a tag, a local-only branch, or no reachable remote), + * the ref is returned as-is for git to resolve during worktree creation. + * + * Never fails: an unresolvable ref surfaces later as a worktree-creation error, + * which carries the real git cause. + * + */ + const resolveWorktreeBase = (input: { + readonly cwd: string; + readonly baseRef: string; + }): Effect.Effect<{ readonly refName: string; readonly baseRefName: string | null }> => + Effect.gen(function* () { + // A failed fetch only means we resolve against the last-known remote state + // The tracking ref may still exist locally + yield* gitWorkflowService + .fetchRemote({ + cwd: input.cwd, + remoteName: "origin", + }) + .pipe( + Effect.catch((cause) => + Effect.logDebug("NTBS fetch of origin failed; resolving against local state.", { + cwd: input.cwd, + cause, + }), + ), + ); + + return yield* gitWorkflowService + .resolveRemoteTrackingCommit({ + cwd: input.cwd, + refName: input.baseRef, + fallbackRemoteName: "origin", + }) + .pipe( + Effect.map((resolved) => ({ + refName: resolved.commitSha, + baseRefName: input.baseRef, + })), + Effect.catch((cause) => + Effect.logDebug("NTBS base ref is not a remote branch; using it as-is", { + baseRef: input.baseRef, + cwd: input.cwd, + cause, + }).pipe( + Effect.as({ + refName: input.baseRef, + baseRefName: null, + }), + ), + ), + ); + }); + + const orchestrationEngineService = yield* OrchestrationEngineService; + + const projectScriptRunner = yield* ProjectSetupScriptRunner; /** * Creates an isolated worktree and a new T3 thread. * - * Uses the supplied project and revision. The thread starts with T3's default + * Uses the supplied project and base ref. The thread starts with T3's default * title, the project's default model or T3's fallback model, `full-access` * runtime mode, and `default` interaction mode. * @@ -200,9 +291,118 @@ export const makeNTBSProcessor =

( * The final title of the thread is generated by T3 after the first turn starts. */ const createT3Thread = (t3Context: T3Context): Effect.Effect => - Effect.sync(function () { - // TODO: Continue from here - const threadId = ThreadId.make("somethread"); + Effect.gen(function* () { + const maybeProject = yield* projectionSnapshotQuery + .getProjectShellById(t3Context.projectId) + .pipe(orFail("Could not load the T3 Project.")); + + const project = yield* Effect.fromOption(maybeProject).pipe( + orFail(`T3 project ${t3Context.projectId} does not exist.`), + ); + + const threadUUID = yield* randomUUID; + const threadId = ThreadId.make(threadUUID); + + const createdAt = DateTime.formatIso(yield* DateTime.now); + // TODO: Resolve the title in a better way + const title = DEFAULT_THREAD_TITLE; + const modelSelection = + project.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); + + const commandId = CommandId.make(yield* randomUUID); + + // create the isolated branch and worktree + const branchName = buildTemporaryWorktreeBranchName(() => threadUUID); + + const base = yield* resolveWorktreeBase({ + cwd: project.workspaceRoot, + baseRef: t3Context.baseRef, + }); + + const gitWorktree = yield* gitWorkflowService + .createWorktree({ + cwd: project.workspaceRoot, + refName: base.refName, + ...(base.baseRefName !== null + ? { + baseRefName: base.baseRefName, + } + : {}), + newRefName: branchName, + path: null, + // we run setup scripts later + deferDependencyInstall: true, + }) + .pipe(orFail("Could not create the T3 worktree")); + + yield* orchestrationEngineService + .dispatch( + OrchestrationCommand.make({ + type: "thread.create", + branch: gitWorktree.worktree.refName, + worktreePath: gitWorktree.worktree.path, + threadId: threadId, + title: title, + modelSelection: modelSelection, + commandId: commandId, + createdAt: createdAt, + projectId: project.id, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + }), + ) + .pipe( + /* + Removes the worktree, but deliberately not its temporary + branch: GitWorkflowService has no branch-delete operation + (branch retention is an invariant of the thread worktree + lifecycle — see WorktreeLifecycle.cleanupThreadWorktree), so + the orphaned `t3/wt-…` ref is an accepted leak. It is a + dangling ref to an existing commit and costs nothing beyond + ref-listing noise. + */ + Effect.onError(() => + gitWorkflowService + .removeWorktree({ + path: gitWorktree.worktree.path, + cwd: project.workspaceRoot, + /* We also want garbage collection, we cannot rely + on the directory to be pristine. + */ + force: true, + }) + .pipe( + Effect.catch((cleanupErr) => + Effect.logWarning( + "Failed to remove worktree after thread.create did not complete", + { + threadId, + path: gitWorktree.worktree.path, + cause: cleanupErr, + }, + ), + ), + ), + ), + orFail("Failed to create a T3 thread"), + ); + + yield* projectScriptRunner + .runForThread({ + threadId, + projectId: project.id, + projectCwd: project.workspaceRoot, + worktreePath: gitWorktree.worktree.path, + }) + .pipe( + Effect.catch((err) => + Effect.logWarning("NTBS thread setup script failed.", { + threadId, + cause: err, + }), + ), + ); + return threadId; }); @@ -221,16 +421,12 @@ export const makeNTBSProcessor =

( const processAdapterRequest = (request: NTBS.NTBSInput

, t3Context: T3Context) => Effect.gen(function* () { - const existingRequest = yield* adapter.findByRequest(request).pipe( - Effect.mapError( - () => - new NTBSProcessorError({ - reason: "Error getting the existing request in processAdapterRequest", - }), - ), - ); + const existingRequest = yield* adapter + .findByRequest(request) + .pipe(orFail("Error getting the existing request in processAdapterRequest")); + if (existingRequest) { - return Effect.void; + return; } else { // create the worktree and T3 thread // generate the first user message ID and record it with ThreadStarted @@ -238,7 +434,7 @@ export const makeNTBSProcessor =

( // start monitoring the turn in the background (TODO: aren't we already subscribing for this?) // Attempt to post the acknowledgement independently } - return Effect.void; + return; }); const process = (request: NTBS.NTBSInput

, t3Context: T3Context) => diff --git a/docs/planning/create-thread.adversarial-review.md b/docs/planning/create-thread.adversarial-review.md new file mode 100644 index 000000000000..92f82b32ec81 --- /dev/null +++ b/docs/planning/create-thread.adversarial-review.md @@ -0,0 +1,60 @@ +# Adversarial review: `createT3Thread` (NTBS processor) + +Target: `createT3Thread` in [apps/server/src/ntbs/processor.ts](../../apps/server/src/ntbs/processor.ts) +(lines ~219–308 at review time). + +Compared against its two existing siblings, which encode lessons this code has not absorbed yet: + +- Jira auto-create flow: [apps/server/src/jira/JiraIssueBridge.ts](../../apps/server/src/jira/JiraIssueBridge.ts) (~395–499) +- ws bootstrap flow: [apps/server/src/ws.ts](../../apps/server/src/ws.ts) (~1098–1156) + +## Business-logic cracks (ranked) + +### 1. Worktree leak on `thread.create` failure — FIXED + +### 2. Every error cause is thrown away — FIXED + +### 3. `t3Context.baseRef` is used raw — FIXED + +The field is renamed `revision` → `baseRef` with its contract documented on `T3Context` and in +`ntbs-architecture.md`. `resolveWorktreeBase` implements the resolution: fetch `origin` (failure +tolerated separately, so an offline host still resolves against its last-known tracking ref), then +prefer the remote state via `resolveRemoteTrackingCommit` (passing `baseRefName` for merge-base +metadata), falling back to raw passthrough where git resolves the ref itself and a genuine failure +surfaces from `createWorktree` with its cause. No new `GitWorkflowService` surface was needed. + +Deferred detail: empty `baseRef` is not rejected up front — it fails in `createWorktree` with a git +cause instead of a crisp contract error. Revisit when the first inbound layer produces the value. + +### 4. Missing `deferDependencyInstall` — FIXED + +`createT3Thread` now passes `deferDependencyInstall: true` to `createWorktree`, matching the ws +pattern, since setup scripts run afterwards. + +### 5. Duplicate-request race (adjacent — `processAdapterRequest`) + +Not in `createT3Thread` itself, but directly feeds it. Still open: + +- The `findByRequest` → create sequence has no atomicity, and webhooks _do_ redeliver + concurrently. Two identical deliveries both pass the check and both create threads. Queuing is + explicitly deferred, but at minimum the adapter's `ThreadStarted` record insert should be + unique-keyed on the platform request so the second creation fails loudly. + +(The `return Effect.void` smell noted here earlier is fixed — both sites use a bare `return`.) + +## Simplification / abstraction + +### Smaller cleanups + +- `buildTemporaryWorktreeBranchName(() => threadUUID)` works and is a tested pattern + (`packages/shared/src/git.test.ts`), but it ignores the callback's `byteLength` parameter and + truncates the UUID to 8 hex chars — to a reader it looks like a bug. Either a short comment or a + dedicated `buildWorktreeBranchNameFromThreadId(threadUUID)` wrapper in `shared/git` would make + the intent explicit. + +## Deliberate choice needing a conscious sign-off + +`runtimeMode: "full-access"` for threads triggered by _external platform actors_. Jira does the +same, so it is consistent — but it means anyone who passes the platform trigger/actor check gets an +unrestricted agent in the repo. Fine if the actor checks are the trust boundary; write that down +where the boundary is enforced. diff --git a/docs/planning/ideas.md b/docs/planning/ideas.md index 2790f25dc530..b6a3740094e0 100644 --- a/docs/planning/ideas.md +++ b/docs/planning/ideas.md @@ -31,3 +31,32 @@ Once every external platform has moved to NTBS, remove these fields and the old The first NTBS adapters can run inside the T3 server, but some platform integrations may remain separate programs. The current Discord bot is one example. When a remote adapter is implemented, either move its platform operations into the server or expose the processor and adapter operations through a network API. The shared lifecycle and storage design should not require every adapter to share the T3 server process. Choose the transport when the first remote adapter is ported. + +## Add worktree cleanup to the Jira bridge + +The Jira auto-create flow (`JiraIssueBridge.ts`) creates a worktree before dispatching +`thread.create` but has no compensation: a failed dispatch orphans the branch and worktree. The +NTBS processor fixed this with `Effect.onError` → forced `removeWorktree` (cleanup errors logged, +original cause re-raised). Rather than patching the bridge separately, extract the shared +`provisionThreadWorktree` helper proposed in `create-thread.adversarial-review.md` and let both +flows use it — the Jira bridge is expected to collapse onto NTBS eventually anyway. + +## Delete the temporary branch when thread provisioning fails + +When `thread.create` fails after `createWorktree`, the NTBS processor removes the worktree but +retains the `t3/wt-…` branch (documented in `processor.ts` as an accepted leak). Everywhere else, +branch retention is deliberate — `WorktreeLifecycle.cleanupThreadWorktree` keeps the branch so +`restoreThreadWorktree` can recreate the worktree on unarchive — but a failed provision has no +thread and nothing restorable, so retention buys nothing there. + +If the ref noise ever matters, the shape is: + +- Add `deleteTemporaryWorktreeBranch({ cwd, refName })` to `GitWorkflowService`, hard-guarded with + `isTemporaryWorktreeBranch` so it structurally cannot delete a real branch. Plumbing precedent: + checkpoint refs are deleted via `update-ref -d` in `GitVcsDriver.ts`, which also skips the + checked-out/merged safety checks. +- In the processor's failure cleanup: `removeWorktree({ force: true })` first, then the branch + delete (git refuses to delete a branch still checked out in a worktree), each step best-effort + with its own log warning. +- Comment on the service op why this exception to branch retention exists, so it is not + "harmonized" with `cleanupThreadWorktree`'s keep-the-branch behavior. diff --git a/docs/planning/ntbs-architecture.md b/docs/planning/ntbs-architecture.md index b47cf90e0bdc..a1aeebd8c404 100644 --- a/docs/planning/ntbs-architecture.md +++ b/docs/planning/ntbs-architecture.md @@ -24,9 +24,9 @@ Storage and retention are adapter implementation details, not architecture decis ## Passing T3 context -An incoming platform event carries both platform data and the T3 context needed to start work, such as the project, revision, and execution context. The adapter forwards that T3 context to T3 when it creates the new thread. +An incoming platform event carries both platform data and the T3 context needed to start work, such as the project, base ref, and execution context. The base ref is the starting point for the thread's worktree — usually a branch name such as `main`, resolved against `origin` before use, or a commit SHA used as-is. The adapter forwards that T3 context to T3 when it creates the new thread. -`NtbsEvent` does not retain the project, revision, or execution context as lifecycle data. Once T3 creates the thread, T3 owns that information. Keeping copies in `NtbsEvent` would require the adapter to keep them in sync with T3. +`NtbsEvent` does not retain the project, base ref, or execution context as lifecycle data. Once T3 creates the thread, T3 owns that information. Keeping copies in `NtbsEvent` would require the adapter to keep them in sync with T3. ## Receiving T3 outcomes From ac1c569cca62484770b0b31e7773f0e8595a36d5 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 10 Aug 2026 23:13:11 +0200 Subject: [PATCH 041/110] feat: added soft concurrency lock --- apps/server/src/ntbs/adapter.ts | 17 +++++++ apps/server/src/ntbs/processor.ts | 46 ++++++++++++++----- .../create-thread.adversarial-review.md | 28 +++++++---- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index f033496f1ef2..f0a7a9aeefdb 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -55,6 +55,23 @@ export interface NTBSAdapter

{ response: NTBSResponse, ) => Effect.Effect; + /** + * Derives the stable identity of a platform request. + * + * The same platform request must always produce the same key, + * across redeliveries and restarts. Distinct requests must produce + * distinct keys. + * + * This is the same identity `findByRequest` looks up, typically the + * platform's own message or event ID, e.g. a Jira comment ID or a + * Discord message ID. + * + * The processor uses it to serialize concurrent deliveries of the + * same request. It is also the natural unique key for the adapter's + * stored lifecycle records. + */ + readonly getRequestKey: (request: NTBS.NTBSInput

) => string; + /** * Finds lifecycle data already recorded for this platform request. * diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 5e19d1fa5d80..769a5fa85971 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -279,6 +279,8 @@ export const makeNTBSProcessor =

( const projectScriptRunner = yield* ProjectSetupScriptRunner; + const inFlightRequests = new Set(); + /** * Creates an isolated worktree and a new T3 thread. * @@ -421,20 +423,40 @@ export const makeNTBSProcessor =

( const processAdapterRequest = (request: NTBS.NTBSInput

, t3Context: T3Context) => Effect.gen(function* () { - const existingRequest = yield* adapter - .findByRequest(request) - .pipe(orFail("Error getting the existing request in processAdapterRequest")); - - if (existingRequest) { + /* + In-flight dedup first. We check if the processor is *currently* + working on this very request: it's being worked right now. + Later we check for the *durable* dedup: are we receiving a request + for work that has *already* completed. + */ + const key = adapter.getRequestKey(request); + + const isBeingWorkedNow = inFlightRequests.has(key); + if (isBeingWorkedNow) { + yield* Effect.logDebug("NTBS request already being worked on; dropping duplicate", { + key, + }); return; - } else { - // create the worktree and T3 thread - // generate the first user message ID and record it with ThreadStarted - // Start the first T3 turn with that message Id, the snapshot and attachments - // start monitoring the turn in the background (TODO: aren't we already subscribing for this?) - // Attempt to post the acknowledgement independently } - return; + inFlightRequests.add(key); + + yield* Effect.gen(function* () { + // durable dedup + const existingRequest = yield* adapter + .findByRequest(request) + .pipe(orFail("Error getting the existing request in processAdapterRequest")); + + if (existingRequest) { + return; + } else { + // create the worktree and T3 thread + // generate the first user message ID and record it with ThreadStarted + // Start the first T3 turn with that message Id, the snapshot and attachments + // start monitoring the turn in the background (TODO: aren't we already subscribing for this?) + // Attempt to post the acknowledgement independently + } + return; + }).pipe(Effect.ensuring(Effect.sync(() => inFlightRequests.delete(key)))); }); const process = (request: NTBS.NTBSInput

, t3Context: T3Context) => diff --git a/docs/planning/create-thread.adversarial-review.md b/docs/planning/create-thread.adversarial-review.md index 92f82b32ec81..e803b1ffa3d4 100644 --- a/docs/planning/create-thread.adversarial-review.md +++ b/docs/planning/create-thread.adversarial-review.md @@ -31,14 +31,26 @@ cause instead of a crisp contract error. Revisit when the first inbound layer pr `createT3Thread` now passes `deferDependencyInstall: true` to `createWorktree`, matching the ws pattern, since setup scripts run afterwards. -### 5. Duplicate-request race (adjacent — `processAdapterRequest`) - -Not in `createT3Thread` itself, but directly feeds it. Still open: - -- The `findByRequest` → create sequence has no atomicity, and webhooks _do_ redeliver - concurrently. Two identical deliveries both pass the check and both create threads. Queuing is - explicitly deferred, but at minimum the adapter's `ThreadStarted` record insert should be - unique-keyed on the platform request so the second creation fails loudly. +### 5. Duplicate-request race (adjacent — `processAdapterRequest`) — MOSTLY FIXED + +Concurrent duplicates are now refused, not raced. The design: + +- `NTBSAdapter.getRequestKey(request)` defines the stable identity of a platform request + (deterministic, distinct per request, stable across redeliveries) — the same identity + `findByRequest` looks up. +- `processAdapterRequest` keeps an in-flight `Set` of keys: check-and-add happens synchronously + before the first yield (single-threaded, so no race), a present key drops the duplicate with a + debug log, and `Effect.ensuring` — wrapping only the admitted work, so a dropped duplicate + cannot erase the winner's key — removes the key on success, failure, or interruption. +- `findByRequest` remains as the durable dedup for later redeliveries (after completion or + restart); the set only covers requests running right now. +- Waiting/queueing duplicates behind the winner was considered and rejected: reliability is the + winner's own job (a bounded `Effect.retry` around creation — still TODO), not a side effect of a + duplicate happening to be queued. + +Remaining follow-up, deferred to the adapter storage schema work: a unique constraint on the +request key for stored `ThreadStarted` records, as the durable backstop for what the in-process +set cannot see (crash mid-creation, multi-process future). (The `return Effect.void` smell noted here earlier is fixed — both sites use a bare `return`.) From 2bc10c2cea03fc5dd8060450f22feacea3a482a1 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 10 Aug 2026 23:23:55 +0200 Subject: [PATCH 042/110] feat: create threadstarted in processor --- apps/server/src/ntbs/processor.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 769a5fa85971..2f2eb17949c7 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -2,7 +2,7 @@ import { type ChatAttachment, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, - type MessageId, + MessageId, OrchestrationCommand, type OrchestrationEvent, type ProjectId, @@ -18,7 +18,6 @@ import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; -import { setInputType } from "effect/Schedule"; /* NTBS architecture: @@ -450,7 +449,23 @@ export const makeNTBSProcessor =

( return; } else { // create the worktree and T3 thread + const threadId = yield* createT3Thread(t3Context); // generate the first user message ID and record it with ThreadStarted + const userMessageId = MessageId.make(yield* randomUUID); + + const threadStarted: NTBS.ThreadStarted

= { + ...request, + state: "thread.started", + t3Data: { + threadId, + userMessageId, + }, + }; + + yield* adapter + .save(threadStarted) + .pipe(orFail("Failed to record the started NTBS thread")); + // Start the first T3 turn with that message Id, the snapshot and attachments // start monitoring the turn in the background (TODO: aren't we already subscribing for this?) // Attempt to post the acknowledgement independently From 20716adce0f472d1491212623ad6c7133fa7138e Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 11 Aug 2026 00:00:05 +0200 Subject: [PATCH 043/110] feat: implement start t3 turn --- apps/server/src/ntbs/adapter.ts | 12 +-- apps/server/src/ntbs/lifecycle.ts | 11 ++- apps/server/src/ntbs/processor.ts | 74 +++++++++++++------ .../create-thread.adversarial-review.md | 2 +- docs/planning/ideas.md | 6 +- docs/planning/ntbs-architecture.md | 8 +- 6 files changed, 71 insertions(+), 42 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index f0a7a9aeefdb..5ae7bf60e4ca 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -41,7 +41,7 @@ export interface NTBSAdapter

{ * Returns the platform's identifier for the posted message. */ readonly postAcknowledgement: ( - state: NTBS.ThreadStarted

, + state: NTBS.ThreadCreated

, ) => Effect.Effect; /** * Posts the final T3 outcome at the response destination described @@ -51,7 +51,7 @@ export interface NTBSAdapter

{ * The processor uses that identifier to save `ResponsePosted`. */ readonly postResponse: ( - state: NTBS.ThreadStarted

, + state: NTBS.ThreadCreated

, response: NTBSResponse, ) => Effect.Effect; @@ -79,7 +79,7 @@ export interface NTBSAdapter

{ * source data. * Returns `null` when no T3 thread has been recorded and processing * may continue. - * Any lifecycle state means the request has already started T3 work. + * Any lifecycle state means the request already has a T3 thread. */ readonly findByRequest: ( request: NTBS.NTBSInput

, @@ -92,7 +92,7 @@ export interface NTBSAdapter

{ * message exists. */ readonly findMatchingResponseMessage: ( - state: NTBS.ThreadStarted

, + state: NTBS.ThreadCreated

, response: NTBSResponse, ) => Effect.Effect; /** @@ -105,11 +105,11 @@ export interface NTBSAdapter

{ threadId: ThreadId, ) => Effect.Effect, ThreadNotFound | AdapterError>; /** - * Loads records that reached `ThreadStarted` but have no recorded + * Loads records that reached `ThreadCreated` but have no recorded * `ResponsePosted` state. */ readonly loadThreadsAwaitingResponse: Effect.Effect< - ReadonlyArray>, + ReadonlyArray>, AdapterError >; } diff --git a/apps/server/src/ntbs/lifecycle.ts b/apps/server/src/ntbs/lifecycle.ts index 1fc1c5d9cd56..cb856d0c6724 100644 --- a/apps/server/src/ntbs/lifecycle.ts +++ b/apps/server/src/ntbs/lifecycle.ts @@ -45,9 +45,12 @@ export type ThreadEvent

= NTBSInput

& { }; }; -export type ThreadStarted

= ThreadEvent

& { - /** T3 has created the new thread. */ - state: "thread.started"; +export type ThreadCreated

= ThreadEvent

& { + /** + * T3 has created the new thread and the adapter has recorded its relationship + * to the platform request. The first turn may not have started yet. + */ + state: "thread.created"; }; export type ResponsePosted

= ThreadEvent

& { @@ -55,4 +58,4 @@ export type ResponsePosted

= ThreadEvent

& { responseMessageId: string; }; -export type NTBSLifecycle

= ThreadStarted

| ResponsePosted

; +export type NTBSLifecycle

= ThreadCreated

| ResponsePosted

; diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 2f2eb17949c7..0896167bc450 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -25,7 +25,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; 1. Generic NTBS processor: - Runs the shared workflow for every platform. - Creates a fresh worktree and T3 thread. - - Saves `ThreadStarted`. + - Saves `ThreadCreated`. - Starts the first turn with the snapshot and attachments. - Monitors the turn for completion and timeouts. - Attempts to post the acknowledgement independently. @@ -85,8 +85,9 @@ export interface NTBSProcessor

{ /** * Consumes T3 events and passes them to `processT3Event`. * - * After the live subscription begins, loads stored `ThreadStarted` records - * and restarts their monitors from each turn's original `requestedAt` time. + * After the live subscription begins, loads stored `ThreadCreated` records. + * It starts a missing first turn or restarts its monitor from the turn's + * original `requestedAt` time. * * Runs until interrupted by its caller. * Logs individual processing failures and continues with later events. @@ -120,19 +121,6 @@ type NTBSProcessorRequirements = */ | Crypto.Crypto; -/** - * Starts the first turn in an existing T3 thread. - * - * Uses the user message ID recorded in `ThreadStarted` so the resulting turn - * and response can be matched to the external request. - */ -declare const startT3Turn: ( - threadId: ThreadId, - userMessageId: MessageId, - snapshot: string, - attachments: ReadonlyArray, -) => Effect.Effect; - /** * Monitors one started T3 turn without blocking request processing. * @@ -146,7 +134,7 @@ declare const startT3Turn: ( * The timed-out thread remains unarchived for inspection or manual retry. */ declare const monitorT3Turn:

( - state: NTBS.ThreadStarted

, + state: NTBS.ThreadCreated

, ) => Effect.Effect; /** @@ -214,6 +202,42 @@ export const makeNTBSProcessor =

( const gitWorkflowService = yield* GitWorkflowService; + /** + * Starts the first turn in an existing T3 thread. + * + * Uses the user message ID recorded in `ThreadCreated` so the resulting turn + * and response can be matched to the external request. + */ + const startT3Turn = ( + threadId: ThreadId, + userMessageId: MessageId, + snapshot: string, + attachments: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + const commandId = CommandId.make(yield* randomUUID); + const createdAt = DateTime.formatIso(yield* DateTime.now); + + yield* orchestrationEngineService + .dispatch( + OrchestrationCommand.make({ + type: "thread.turn.start", + commandId, + threadId, + message: { + messageId: userMessageId, + role: "user", + text: snapshot, + attachments, + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt, + }), + ) + .pipe(orFail("Failed to start the first T3 turn")); + }); + /** * Resolves where a new thread worktree starts from. * @@ -411,10 +435,10 @@ export const makeNTBSProcessor =

( Handles an external request in this order: 1. Ask the adapter whether this platform request already has a recorded - `ThreadStarted` or `ResponsePosted`. + `ThreadCreated` or `ResponsePosted`. If yes - stop. . If no - continue 2. Create the worktree and T3 thread. - 3. Generate the first user message ID and record it with ThreadStarted. + 3. Generate the first user message ID and record it with ThreadCreated. 4. Start the first T3 turn with that message ID, the snapshot, and attachments. 5. Start monitoring the turn in the background. 6. Attempt to post the acknowledgement independently. @@ -450,12 +474,13 @@ export const makeNTBSProcessor =

( } else { // create the worktree and T3 thread const threadId = yield* createT3Thread(t3Context); - // generate the first user message ID and record it with ThreadStarted + + // generate the first user message ID and record it with ThreadCreated const userMessageId = MessageId.make(yield* randomUUID); - const threadStarted: NTBS.ThreadStarted

= { + const threadCreated: NTBS.ThreadCreated

= { ...request, - state: "thread.started", + state: "thread.created", t3Data: { threadId, userMessageId, @@ -463,10 +488,11 @@ export const makeNTBSProcessor =

( }; yield* adapter - .save(threadStarted) - .pipe(orFail("Failed to record the started NTBS thread")); + .save(threadCreated) + .pipe(orFail("Failed to record the created NTBS thread")); // Start the first T3 turn with that message Id, the snapshot and attachments + yield* startT3Turn(threadId, userMessageId, request.snapshot, request.attachments); // start monitoring the turn in the background (TODO: aren't we already subscribing for this?) // Attempt to post the acknowledgement independently } diff --git a/docs/planning/create-thread.adversarial-review.md b/docs/planning/create-thread.adversarial-review.md index e803b1ffa3d4..3a453b50108c 100644 --- a/docs/planning/create-thread.adversarial-review.md +++ b/docs/planning/create-thread.adversarial-review.md @@ -49,7 +49,7 @@ Concurrent duplicates are now refused, not raced. The design: duplicate happening to be queued. Remaining follow-up, deferred to the adapter storage schema work: a unique constraint on the -request key for stored `ThreadStarted` records, as the durable backstop for what the in-process +request key for stored `ThreadCreated` records, as the durable backstop for what the in-process set cannot see (crash mid-creation, multi-process future). (The `return Effect.void` smell noted here earlier is fixed — both sites use a bare `return`.) diff --git a/docs/planning/ideas.md b/docs/planning/ideas.md index b6a3740094e0..ead13d5f72e5 100644 --- a/docs/planning/ideas.md +++ b/docs/planning/ideas.md @@ -4,7 +4,7 @@ There is a tradeoff between recovering every possible interruption and keeping the first implementation simple. A saved `RequestAccepted` state could recover the rare case where the server receives a request but stops before creating its T3 thread. Doing that safely would also require planned thread IDs, startup searches, retries, and duplicate handling. -For now, the shared lifecycle should begin with `ThreadStarted`. The processor should save it as soon as the basic T3 thread exists, before slower preparation begins. A request can be lost if the server stops before that point, but the missing acknowledgement makes the failure visible and the user can send the request again. +For now, the shared lifecycle should begin with `ThreadCreated`. The processor should save it as soon as the basic T3 thread exists, before slower preparation begins. A request can be lost if the server stops before that point, but the missing acknowledgement makes the failure visible and the user can send the request again. A stronger recovery system can be added later if real usage requires it. Each adapter could inspect recent messages on its platform, find requests that have no corresponding T3 thread, and submit them again. This belongs to the adapter because Jira, GitHub, Discord, and Teams provide different ways to read their recent messages. @@ -12,11 +12,11 @@ A stronger recovery system can be added later if real usage requires it. Each ad The acknowledgement is platform feedback, such as a "working on it" message. It should not be a required stage in the shared NTBS lifecycle because failing to post it, or failing to save its message ID, must not prevent the processor from representing and posting the final response. -Remove `ThreadStartedAcknowledgement` from the lifecycle and remove `acknowledgementMessageId` from `ResponseAvailable` and `ResponsePosted`. The adapter may still post an acknowledgement and retain its identifier in its own platform-specific storage when needed. When posting the final response, the adapter can reply to the acknowledgement or fall back to the original source message according to the platform's capabilities. +Remove `ThreadCreatedAcknowledgement` from the lifecycle and remove `acknowledgementMessageId` from `ResponseAvailable` and `ResponsePosted`. The adapter may still post an acknowledgement and retain its identifier in its own platform-specific storage when needed. When posting the final response, the adapter can reply to the acknowledgement or fall back to the original source message according to the platform's capabilities. The shared sequence becomes: -`Create the T3 thread → record ThreadStarted → start the work and attempt the acknowledgement independently` +`Create the T3 thread → record ThreadCreated → start the work and attempt the acknowledgement independently` The processor does not use acknowledgement success as a condition for continuing. Posting may happen alongside the start of T3 work, and an adapter may retry a failed acknowledgement, but the final answer always remains tied to the original response destination. If a platform benefits from replying to the acknowledgement, its adapter can use the identifier stored in its own data without adding that dependency to the shared lifecycle. diff --git a/docs/planning/ntbs-architecture.md b/docs/planning/ntbs-architecture.md index a1aeebd8c404..12e14147b13a 100644 --- a/docs/planning/ntbs-architecture.md +++ b/docs/planning/ntbs-architecture.md @@ -73,7 +73,7 @@ type PlatformData = { */ type NtbsEvent

> = | NtbsEventAccepted

- | NtbsEventThreadStarted

+ | NtbsEventThreadCreated

| NtbsEventAcknowledgementPosted

| NtbsEventOutcomeAvailable

| NtbsEventResponsePosted

; @@ -102,9 +102,9 @@ type NtbsEventWithThread

> = NtbsEventBa }; }; -type NtbsEventThreadStarted

> = NtbsEventWithThread

& { +type NtbsEventThreadCreated

> = NtbsEventWithThread

& { /** T3 has created the new thread from the source snapshot. */ - state: "threadStarted"; + state: "threadCreated"; }; type NtbsEventWithAcknowledgement

> = @@ -162,7 +162,7 @@ A user adds top-level Jira comment `10401` on issue `T3-123`: `@agent investigat When T3 creates the work, the adapter adds its IDs: ```ts -state: "threadStarted", +state: "threadCreated", t3: { threadId: "thread-1", userMessageId: "message-1", From f381b2c79f6a4901a90e1151fca53ce6cb185dc1 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 11 Aug 2026 15:21:11 +0200 Subject: [PATCH 044/110] feat: implement get progression logic --- apps/server/src/ntbs/processor.ts | 146 +++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 0896167bc450..e9833495f48f 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -2,9 +2,13 @@ import { type ChatAttachment, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + type EventId, MessageId, OrchestrationCommand, type OrchestrationEvent, + type OrchestrationLatestTurn, + type OrchestrationLatestTurnState, + type OrchestrationThread, type ProjectId, ThreadId, } from "@t3tools/contracts"; @@ -121,6 +125,66 @@ type NTBSProcessorRequirements = */ | Crypto.Crypto; +type TurnStats = { + readonly state: OrchestrationLatestTurnState; + readonly activityCount: number; + readonly latestActivityId: EventId | null; + readonly assistantTextLength: number; + readonly assistantUpdatedAt: string | null; +}; + +/** + * Gets the statistics visible in T3's projected activities and assistant + * messages for one turn. It includes recorded tool activity and assistant text + * that has reached the projection, but not necessarily buffered output, + * hidden reasoning, or provider work that produces no projected event. + * Comparing two results can indicate observable progress, but an unchanged + * result does not prove that the turn is stalled. + */ +const getTurnStats = (thread: OrchestrationThread, turn: OrchestrationLatestTurn): TurnStats => { + const activities = thread.activities.filter((activity) => activity.turnId === turn.turnId); + const assistantMessages = thread.messages.filter( + (message) => message.turnId === turn.turnId && message.role === "assistant", + ); + + const assistantUpdatedAt = assistantMessages.reduce( + (latest, message) => + latest === null || message.updatedAt > latest ? message.updatedAt : latest, + null, + ); + + return { + state: turn.state, + activityCount: activities.length, + latestActivityId: activities.at(-1)?.id ?? null, + assistantTextLength: assistantMessages.reduce( + (length, message) => length + message.text.length, + 0, + ), + assistantUpdatedAt, + }; +}; + +const hasProgress = (previous: TurnStats, current: TurnStats): boolean => + previous.activityCount !== current.activityCount || + previous.latestActivityId !== current.latestActivityId || + previous.assistantTextLength !== current.assistantTextLength || + previous.assistantUpdatedAt !== current.assistantUpdatedAt; + +/** + * Records what the processor observed when it last checked a T3 turn. + * + * `stats` is null while T3 has accepted the turn request but the provider has + * not started the turn. Once the turn exists, `stats.state` is the single + * source of truth for whether it is running or terminal. + */ +type TurnStatus = { + /** When the processor read this status from the T3 projection. */ + readonly recordedAt: string; + /** The observed turn statistics, or null while the turn is still pending. */ + readonly stats: TurnStats | null; +}; + /** * Monitors one started T3 turn without blocking request processing. * @@ -202,6 +266,8 @@ export const makeNTBSProcessor =

( const gitWorkflowService = yield* GitWorkflowService; + const getNow = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + /** * Starts the first turn in an existing T3 thread. * @@ -216,7 +282,7 @@ export const makeNTBSProcessor =

( ): Effect.Effect => Effect.gen(function* () { const commandId = CommandId.make(yield* randomUUID); - const createdAt = DateTime.formatIso(yield* DateTime.now); + const createdAt = yield* getNow; yield* orchestrationEngineService .dispatch( @@ -302,8 +368,84 @@ export const makeNTBSProcessor =

( const projectScriptRunner = yield* ProjectSetupScriptRunner; + /** + * Semaphore-like behavior to avoid triggering multiple threads + * and turns for the same requests. + */ const inFlightRequests = new Set(); + /** + * Keeps stats of active threads. + * + * Used to find out whether a turn has progressed since last check + * or is it hanging. + */ + const threadStatus = new Map(); + + /** + * Fetches fresh turn information from T3 + */ + const loadThreadStatus = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + const maybeThread = yield* projectionSnapshotQuery + .getThreadDetailById(threadId) + .pipe(orFail("Problems getting the thread from the projection")); + + const thread = yield* Effect.fromOption(maybeThread).pipe( + orFail(`Could not load T3 thread ${threadId}`), + ); + + if (!thread.latestTurn) { + return { stats: null, recordedAt: yield* getNow }; + } + + const stats = getTurnStats(thread, thread.latestTurn); + return { stats, recordedAt: yield* getNow }; + }); + + /** + * Loads the current turn status and compares it with the previous observation. + * The first observation establishes the baseline and reports `progressed` as null. + * Nonterminal observations replace the stored baseline; terminal observations remove it. + */ + const getProgress = ( + threadId: ThreadId, + ): Effect.Effect< + { readonly status: TurnStatus; readonly progressed: boolean | null }, + NTBSProcessorError + > => + Effect.gen(function* () { + const recorded = threadStatus.get(threadId); + const fresh = yield* loadThreadStatus(threadId); + + let progressed: boolean | null; + + if (recorded === undefined) { + progressed = null; + } else if (recorded.stats === null && fresh.stats === null) { + progressed = false; + } else if (recorded.stats === null) { + progressed = true; + } else if (fresh.stats === null) { + return yield* new NTBSProcessorError({ + reason: `T3 thread ${threadId} became pending after its turn had started.`, + cause: { recorded, fresh }, + }); + } else { + progressed = hasProgress(recorded.stats, fresh.stats); + } + + const finished = fresh.stats !== null && fresh.stats.state !== "running"; + + if (finished) { + threadStatus.delete(threadId); + } else { + threadStatus.set(threadId, fresh); + } + + return { status: fresh, progressed }; + }); + /** * Creates an isolated worktree and a new T3 thread. * @@ -328,7 +470,7 @@ export const makeNTBSProcessor =

( const threadUUID = yield* randomUUID; const threadId = ThreadId.make(threadUUID); - const createdAt = DateTime.formatIso(yield* DateTime.now); + const createdAt = yield* getNow; // TODO: Resolve the title in a better way const title = DEFAULT_THREAD_TITLE; const modelSelection = From be64e45a4b7e3c16543152018025cf64270f5b62 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 11 Aug 2026 17:52:11 +0200 Subject: [PATCH 045/110] chore: more work on monitor --- apps/server/src/ntbs/processor.ts | 52 +++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index e9833495f48f..2c3237df1e31 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -22,6 +22,7 @@ import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import type { OnDiffLineClickProps } from "@pierre/diffs"; /* NTBS architecture: @@ -185,22 +186,6 @@ type TurnStatus = { readonly stats: TurnStats | null; }; -/** - * Monitors one started T3 turn without blocking request processing. - * - * Starts in the background immediately after `startT3Turn` succeeds. It checks - * the turn 30 minutes after its original `requestedAt` time, then checks again - * at 45 minutes if it is still running. Startup recovery restarts this monitor - * from the original `requestedAt` time rather than resetting the deadline. - * - * If the turn is still running after 45 minutes, interrupts it, waits for T3 to - * confirm it stopped, posts a timeout response, and records `ResponsePosted`. - * The timed-out thread remains unarchived for inspection or manual retry. - */ -declare const monitorT3Turn:

( - state: NTBS.ThreadCreated

, -) => Effect.Effect; - /** * Provider runtimes (like Claude Code) emit `turn.completed` events. * T3 consumes those internally and exposes the resulting session change through a `thread.session-set` event. @@ -408,7 +393,7 @@ export const makeNTBSProcessor =

( * The first observation establishes the baseline and reports `progressed` as null. * Nonterminal observations replace the stored baseline; terminal observations remove it. */ - const getProgress = ( + const checkProgress = ( threadId: ThreadId, ): Effect.Effect< { readonly status: TurnStatus; readonly progressed: boolean | null }, @@ -446,6 +431,39 @@ export const makeNTBSProcessor =

( return { status: fresh, progressed }; }); + // TODO: These guys should come from some config + const CHECK_INTERVAL = "15 seconds"; + const MAX_NO_PROGRESS_CHECKS = 12; + + const monitorT3Turn = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + let consecutiveNoProgressChecks = 0; + + while (true) { + const result = yield* checkProgress(threadId); + const stats = result.status.stats; + + if (stats !== null && stats.state !== "running") { + // it has completed already + return; + } + + if (result.progressed === true) { + // reset the counter + consecutiveNoProgressChecks = 0; + } else if (result.progressed === false) { + consecutiveNoProgressChecks += 1; + } + + if (consecutiveNoProgressChecks >= MAX_NO_PROGRESS_CHECKS) { + yield* Effect.logDebug("No progress for 2 minutes, something's sketchy, check"); + return; + } + + yield* Effect.sleep(CHECK_INTERVAL); + } + }); + /** * Creates an isolated worktree and a new T3 thread. * From fb67c56f02a716fb3673dfc86a018c3f68a7c43c Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 11 Aug 2026 17:52:20 +0200 Subject: [PATCH 046/110] docs: monitor --- docs/planning/monitor-review.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/planning/monitor-review.md diff --git a/docs/planning/monitor-review.md b/docs/planning/monitor-review.md new file mode 100644 index 000000000000..36e2d80e53ad --- /dev/null +++ b/docs/planning/monitor-review.md @@ -0,0 +1,25 @@ +# NTBS turn monitor review + +The monitoring loop has a sound basic structure: it establishes a baseline, checks the projected turn repeatedly, resets its inactivity counter when observable progress appears, and stops when the turn reaches a terminal state. The following issues should be addressed before relying on it operationally. + +## Findings + +1. The monitor reads `thread.latestTurn`, not necessarily the original NTBS turn. If someone starts another turn in the same T3 thread, the monitor can silently switch targets. It should retain or recover the original `userMessageId` and use it to identify the correct turn. + +2. Multiple monitors for the same thread share and overwrite the same `threadStatus` entry. Their alternating checks could make inactivity accumulate too quickly. The processor should track which thread IDs already have an active monitor and refuse to start a duplicate. + +3. The stored status is removed only when a terminal turn is observed. It remains in memory when monitoring stops because of inactivity, failure, or interruption. Monitor termination should always remove the thread's entry from `threadStatus`. + +4. A temporary failure while reading the T3 projection terminates the monitor permanently. A failed check should be logged and retried on a later interval. It should not count as evidence that the turn made no progress. + +5. `loadThreadStatus` treats every missing `latestTurn` as a pending turn. It should verify that `pendingTurnStart` exists and belongs to the expected user message. If neither a pending request nor the expected turn exists, the projected T3 state is inconsistent and should produce an error. + +6. Six unchanged checks at 15-second intervals represent 90 seconds without observable progress, not the two minutes stated by the current log message. A two-minute threshold requires eight unchanged checks. + +7. The current stalled-turn branch only writes a debug log and stops monitoring. The T3 turn continues running without further supervision. The final implementation must apply the chosen stalled-turn policy, such as interrupting the turn and posting a timeout response. + +8. The progress heuristic cannot reliably observe buffered assistant output, hidden reasoning, or provider work that produces no projected event. A healthy turn may therefore appear unchanged. A short inactivity threshold increases the likelihood of false positives, so the initial threshold should be conservative and reviewed using real behavior. + +## Recommended first fix + +Track the original `userMessageId` when monitoring. Correctly identifying the intended turn is required before any progress comparison, timeout, or stalled-turn action can be trusted. From 2b862f6c62f6372fb3ab775f9eabb4ae9cc18a6d Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 11 Aug 2026 18:22:04 +0200 Subject: [PATCH 047/110] fix: turn and message consistency --- apps/server/src/ntbs/processor.ts | 104 ++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 28 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 2c3237df1e31..d1b481bff355 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -17,6 +17,7 @@ import { Context, Crypto, Data, DateTime, Effect } from "effect"; import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; @@ -113,6 +114,10 @@ type NTBSProcessorRequirements = Loads the selected T3 project and reads thread outcomes and archive state. */ | ProjectionSnapshotQuery + /* + Finds the exact projected turn associated with the original T3 user message. + */ + | ProjectionTurnRepository /* Creates the isolated branch and worktree for each external request. */ @@ -142,7 +147,10 @@ type TurnStats = { * Comparing two results can indicate observable progress, but an unchanged * result does not prove that the turn is stalled. */ -const getTurnStats = (thread: OrchestrationThread, turn: OrchestrationLatestTurn): TurnStats => { +const getTurnStats = ( + thread: OrchestrationThread, + turn: Pick, +): TurnStats => { const activities = thread.activities.filter((activity) => activity.turnId === turn.turnId); const assistantMessages = thread.messages.filter( (message) => message.turnId === turn.turnId && message.role === "assistant", @@ -180,6 +188,8 @@ const hasProgress = (previous: TurnStats, current: TurnStats): boolean => * source of truth for whether it is running or terminal. */ type TurnStatus = { + /** The T3 thread containing the monitored user message. */ + readonly threadId: ThreadId; /** When the processor read this status from the T3 projection. */ readonly recordedAt: string; /** The observed turn statistics, or null while the turn is still pending. */ @@ -248,6 +258,7 @@ export const makeNTBSProcessor =

( const randomUUID = crypto.randomUUIDv4.pipe(orFail("Failed creating a UUID v4")); const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projectionTurnRepository = yield* ProjectionTurnRepository; const gitWorkflowService = yield* GitWorkflowService; @@ -360,18 +371,46 @@ export const makeNTBSProcessor =

( const inFlightRequests = new Set(); /** - * Keeps stats of active threads. + * Keeps stats of active NTBS messages. * * Used to find out whether a turn has progressed since last check * or is it hanging. */ - const threadStatus = new Map(); + const messageStatus = new Map(); /** - * Fetches fresh turn information from T3 + * Fetches fresh information for the turn created by one T3 user message. */ - const loadThreadStatus = (threadId: ThreadId): Effect.Effect => + const loadMessageStatus = ( + userMessageId: MessageId, + threadId: ThreadId, + ): Effect.Effect => Effect.gen(function* () { + const turns = yield* projectionTurnRepository + .listByThreadId({ threadId }) + .pipe(orFail(`Could not load projected turns for T3 thread ${threadId}`)); + + const matchingTurns = turns.filter((turn) => turn.pendingMessageId === userMessageId); + const monitoredTurn = matchingTurns[0]; + + if (matchingTurns.length !== 1 || monitoredTurn === undefined) { + return yield* new NTBSProcessorError({ + reason: `Expected exactly one T3 turn for user message ${userMessageId}, found ${matchingTurns.length}.`, + cause: { userMessageId, threadId, matchingTurns }, + }); + } + + if (monitoredTurn.turnId === null && monitoredTurn.state === "pending") { + return { threadId, stats: null, recordedAt: yield* getNow }; + } + + if (monitoredTurn.turnId === null || monitoredTurn.state === "pending") { + return yield* new NTBSProcessorError({ + reason: `T3 turn state is inconsistent for user message ${userMessageId}.`, + cause: monitoredTurn, + }); + } + const maybeThread = yield* projectionSnapshotQuery .getThreadDetailById(threadId) .pipe(orFail("Problems getting the thread from the projection")); @@ -380,40 +419,44 @@ export const makeNTBSProcessor =

( orFail(`Could not load T3 thread ${threadId}`), ); - if (!thread.latestTurn) { - return { stats: null, recordedAt: yield* getNow }; - } - - const stats = getTurnStats(thread, thread.latestTurn); - return { stats, recordedAt: yield* getNow }; + const stats = getTurnStats(thread, { + turnId: monitoredTurn.turnId, + state: monitoredTurn.state, + }); + return { threadId, stats, recordedAt: yield* getNow }; }); /** * Loads the current turn status and compares it with the previous observation. - * The first observation establishes the baseline and reports `progressed` as null. + * The status recorded when monitoring begins is the initial baseline. * Nonterminal observations replace the stored baseline; terminal observations remove it. */ const checkProgress = ( - threadId: ThreadId, + userMessageId: MessageId, ): Effect.Effect< - { readonly status: TurnStatus; readonly progressed: boolean | null }, + { readonly status: TurnStatus; readonly progressed: boolean }, NTBSProcessorError > => Effect.gen(function* () { - const recorded = threadStatus.get(threadId); - const fresh = yield* loadThreadStatus(threadId); + const recorded = messageStatus.get(userMessageId); + if (recorded === undefined) { + return yield* new NTBSProcessorError({ + reason: `No monitoring state exists for T3 user message ${userMessageId}.`, + cause: userMessageId, + }); + } - let progressed: boolean | null; + const fresh = yield* loadMessageStatus(userMessageId, recorded.threadId); - if (recorded === undefined) { - progressed = null; - } else if (recorded.stats === null && fresh.stats === null) { + let progressed: boolean; + + if (recorded.stats === null && fresh.stats === null) { progressed = false; } else if (recorded.stats === null) { progressed = true; } else if (fresh.stats === null) { return yield* new NTBSProcessorError({ - reason: `T3 thread ${threadId} became pending after its turn had started.`, + reason: `T3 thread ${recorded.threadId} became pending after its turn had started.`, cause: { recorded, fresh }, }); } else { @@ -423,9 +466,9 @@ export const makeNTBSProcessor =

( const finished = fresh.stats !== null && fresh.stats.state !== "running"; if (finished) { - threadStatus.delete(threadId); + messageStatus.delete(userMessageId); } else { - threadStatus.set(threadId, fresh); + messageStatus.set(userMessageId, fresh); } return { status: fresh, progressed }; @@ -435,12 +478,12 @@ export const makeNTBSProcessor =

( const CHECK_INTERVAL = "15 seconds"; const MAX_NO_PROGRESS_CHECKS = 12; - const monitorT3Turn = (threadId: ThreadId): Effect.Effect => + const monitorT3Turn = (userMessageId: MessageId): Effect.Effect => Effect.gen(function* () { let consecutiveNoProgressChecks = 0; while (true) { - const result = yield* checkProgress(threadId); + const result = yield* checkProgress(userMessageId); const stats = result.status.stats; if (stats !== null && stats.state !== "running") { @@ -448,10 +491,10 @@ export const makeNTBSProcessor =

( return; } - if (result.progressed === true) { + if (result.progressed) { // reset the counter consecutiveNoProgressChecks = 0; - } else if (result.progressed === false) { + } else { consecutiveNoProgressChecks += 1; } @@ -462,7 +505,7 @@ export const makeNTBSProcessor =

( yield* Effect.sleep(CHECK_INTERVAL); } - }); + }).pipe(Effect.ensuring(Effect.sync(() => messageStatus.delete(userMessageId)))); /** * Creates an isolated worktree and a new T3 thread. @@ -653,6 +696,11 @@ export const makeNTBSProcessor =

( // Start the first T3 turn with that message Id, the snapshot and attachments yield* startT3Turn(threadId, userMessageId, request.snapshot, request.attachments); + messageStatus.set(userMessageId, { + threadId, + recordedAt: yield* getNow, + stats: null, + }); // start monitoring the turn in the background (TODO: aren't we already subscribing for this?) // Attempt to post the acknowledgement independently } From 0011c956202c7e6d0ee66e98f7c832af56f9fef5 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 11 Aug 2026 18:29:15 +0200 Subject: [PATCH 048/110] chore: retry retriable stuff --- apps/server/src/ntbs/processor.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index d1b481bff355..665bd70cd895 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -13,7 +13,7 @@ import { ThreadId, } from "@t3tools/contracts"; import type * as NTBS from "./lifecycle.ts"; -import { Context, Crypto, Data, DateTime, Effect } from "effect"; +import { Context, Crypto, Data, DateTime, Effect, Schedule } from "effect"; import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -483,7 +483,19 @@ export const makeNTBSProcessor =

( let consecutiveNoProgressChecks = 0; while (true) { - const result = yield* checkProgress(userMessageId); + const result = yield* checkProgress(userMessageId).pipe( + Effect.retry({ + times: 3, + schedule: Schedule.spaced(CHECK_INTERVAL), + }), + Effect.tapError((cause) => + Effect.logWarning("Failed checking T3 turn progress after retries", { + userMessageId, + cause, + }), + ), + ); + const stats = result.status.stats; if (stats !== null && stats.state !== "running") { From 07c6f15d057a71e2626ac9c8a26a2791c4c481f4 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 12 Aug 2026 14:18:38 +0200 Subject: [PATCH 049/110] feat: more work on locks and interrupts --- apps/server/src/ntbs/processor.ts | 152 +++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 665bd70cd895..707f477350e8 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -11,9 +11,11 @@ import { type OrchestrationThread, type ProjectId, ThreadId, + type TurnId, } from "@t3tools/contracts"; import type * as NTBS from "./lifecycle.ts"; import { Context, Crypto, Data, DateTime, Effect, Schedule } from "effect"; +import * as Semaphore from "effect/Semaphore"; import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -132,6 +134,7 @@ type NTBSProcessorRequirements = | Crypto.Crypto; type TurnStats = { + readonly turnId: TurnId; readonly state: OrchestrationLatestTurnState; readonly activityCount: number; readonly latestActivityId: EventId | null; @@ -163,6 +166,7 @@ const getTurnStats = ( ); return { + turnId: turn.turnId, state: turn.state, activityCount: activities.length, latestActivityId: activities.at(-1)?.id ?? null, @@ -264,6 +268,38 @@ export const makeNTBSProcessor =

( const getNow = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + /** Keeps one lock per user message so its turn cannot return two final responses at once. */ + const responseLocks = new Map(); + + const getResponseLock = (userMessageId: MessageId): Semaphore.Semaphore => { + let semaphore = responseLocks.get(userMessageId); + if (semaphore === undefined) { + semaphore = Semaphore.makeUnsafe(1); + responseLocks.set(userMessageId, semaphore); + } + return semaphore; + }; + + /** + * Prevents the turn started by one user message from producing competing + * final outcomes, such as both a normal response and a timeout. + */ + const ensureUniqueOutcome = ( + userMessageId: MessageId, + effect: Effect.Effect, + ): Effect.Effect => { + const semaphore = getResponseLock(userMessageId); + return semaphore.withPermit(effect).pipe( + Effect.tap(() => + Effect.sync(() => { + if (responseLocks.get(userMessageId) === semaphore) { + responseLocks.delete(userMessageId); + } + }), + ), + ); + }; + /** * Starts the first turn in an existing T3 thread. * @@ -300,6 +336,109 @@ export const makeNTBSProcessor =

( .pipe(orFail("Failed to start the first T3 turn")); }); + /** + * Requests interruption of one exact T3 turn. + */ + const interruptT3Turn = ( + threadId: ThreadId, + turnId: TurnId, + ): Effect.Effect => + Effect.gen(function* () { + const commandId = CommandId.make(yield* randomUUID); + const createdAt = yield* getNow; + + yield* orchestrationEngineService + .dispatch( + OrchestrationCommand.make({ + type: "thread.turn.interrupt", + commandId, + threadId, + turnId, + createdAt, + }), + ) + .pipe(orFail(`Failed to interrupt T3 turn ${turnId}`)); + }); + + /** + * Posts one final response and records it in the adapter lifecycle. + * + * The caller must have already confirmed that no response is recorded and + * must hold the outcome lock for this user message. If the platform already + * contains the response, it is recorded instead of reposted. + */ + const postResponse = ( + threadCreated: NTBS.ThreadCreated

, + response: NTBSResponse, + ): Effect.Effect => + Effect.gen(function* () { + const existingResponseMessageId = yield* adapter + .findMatchingResponseMessage(threadCreated, response) + .pipe(orFail("Failed checking whether the NTBS response was already posted")); + + const responseMessageId = + existingResponseMessageId ?? + (yield* adapter + .postResponse(threadCreated, response) + .pipe(orFail("Failed posting the NTBS response"))); + + yield* adapter + .save({ + ...threadCreated, + state: "thread.response.posted", + responseMessageId, + }) + .pipe(orFail("Failed recording the posted NTBS response")); + }); + + /** + * Stops a stalled T3 turn and reports the timeout to the external platform. + * + * Response handling is locked by user message so a normal completion and + * timeout cannot both post an outcome. Timed-out threads remain unarchived + * for inspection or manual retry. + */ + const handleStalledTurn = ( + userMessageId: MessageId, + status: TurnStatus, + ): Effect.Effect => + ensureUniqueOutcome( + userMessageId, + Effect.gen(function* () { + const lifecycle = yield* adapter + .findByThreadId(status.threadId) + .pipe(orFail("Failed loading the NTBS lifecycle for a stalled turn")); + + if (lifecycle.state === "thread.response.posted") { + return; + } + + if (status.stats !== null) { + const turnId = status.stats.turnId; + yield* interruptT3Turn(status.threadId, turnId).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed interrupting stalled T3 turn", { + userMessageId, + threadId: status.threadId, + turnId, + cause, + }), + ), + ); + } + + const response: NTBSResponse = { + type: "timeout", + text: + status.stats === null + ? "T3 could not start this request after repeated checks." + : "T3 stopped this request after repeated checks found no observable progress.", + }; + + yield* postResponse(lifecycle, response); + }), + ); + /** * Resolves where a new thread worktree starts from. * @@ -511,7 +650,7 @@ export const makeNTBSProcessor =

( } if (consecutiveNoProgressChecks >= MAX_NO_PROGRESS_CHECKS) { - yield* Effect.logDebug("No progress for 2 minutes, something's sketchy, check"); + yield* handleStalledTurn(userMessageId, result.status); return; } @@ -713,7 +852,16 @@ export const makeNTBSProcessor =

( recordedAt: yield* getNow, stats: null, }); - // start monitoring the turn in the background (TODO: aren't we already subscribing for this?) + yield* monitorT3Turn(userMessageId).pipe( + Effect.catch((cause) => + Effect.logError("NTBS turn monitor failed", { + userMessageId, + threadId, + cause, + }), + ), + Effect.forkDetach, + ); // Attempt to post the acknowledgement independently } return; From 4a2b3df915e4a886cc7daec5fab9fbdbeafb1ac3 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 12 Aug 2026 15:04:09 +0200 Subject: [PATCH 050/110] chore: processor v1 --- apps/server/src/ntbs/processor.ts | 401 ++++++++++++++++++++++++------ docs/planning/ideas.md | 4 + 2 files changed, 329 insertions(+), 76 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 707f477350e8..b0f5ea257c1c 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -14,7 +14,7 @@ import { type TurnId, } from "@t3tools/contracts"; import type * as NTBS from "./lifecycle.ts"; -import { Context, Crypto, Data, DateTime, Effect, Schedule } from "effect"; +import { Context, Crypto, Data, DateTime, Effect, Schedule, Stream } from "effect"; import * as Semaphore from "effect/Semaphore"; import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; @@ -25,7 +25,6 @@ import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; -import type { OnDiffLineClickProps } from "@pierre/diffs"; /* NTBS architecture: @@ -39,7 +38,6 @@ import type { OnDiffLineClickProps } from "@pierre/diffs"; - Attempts to post the acknowledgement independently. - Watches T3 events for completed work. - Posts the final result through the adapter and saves `ResponsePosted`. - - Archives the T3 thread after its response has been recorded. 2. Platform-specific inbound code: - Receives raw platform data from Jira, Discord, GitHub, or Teams. @@ -94,8 +92,8 @@ export interface NTBSProcessor

{ * Consumes T3 events and passes them to `processT3Event`. * * After the live subscription begins, loads stored `ThreadCreated` records. - * It starts a missing first turn or restarts its monitor from the turn's - * original `requestedAt` time. + * It starts a missing first turn, resumes monitoring an active turn, or posts + * the outcome of a turn that already finished. * * Runs until interrupted by its caller. * Logs individual processing failures and continues with later events. @@ -113,7 +111,7 @@ type NTBSProcessorRequirements = */ | OrchestrationEngineService /* - Loads the selected T3 project and reads thread outcomes and archive state. + Loads the selected T3 project and reads thread outcomes. */ | ProjectionSnapshotQuery /* @@ -200,52 +198,6 @@ type TurnStatus = { readonly stats: TurnStats | null; }; -/** - * Provider runtimes (like Claude Code) emit `turn.completed` events. - * T3 consumes those internally and exposes the resulting session change through a `thread.session-set` event. - * - * Native T3 clients can react by refreshing the thread projection. - * External NTBS adapters do not consume T3 projections automatically, so they must read the thread state themselves. - * - * This function reads the projected thread identified by the session event. - * It finds the recorded user message, then resolves the response from that - * message's turn rather than whichever turn happens to be latest. - * It returns `null` if that turn has not ended. - * Otherwise it returns the response and its type. - */ -declare const resolveT3Outcome: ( - event: Extract, - userMessageId: MessageId, -) => Effect.Effect< - { readonly threadId: ThreadId; readonly response: NTBSResponse } | null, - NTBSProcessorError ->; - -/** - * Archives a T3 thread after its external response has been recorded. - * - * Returns successfully when the thread is already archived. A failure can be - * retried without posting the external response again. - * Timed-out threads are left unarchived for inspection or manual retry. - */ -declare const archiveT3Thread: (threadId: ThreadId) => Effect.Effect; - -/** - * Handles T3 events that may indicate that a turn has ended. - * - * 1. Ignore events other than `thread.session-set`. - * 2. Find the adapter record by thread ID. - * 3. Stop if no record exists. - * 4. Stop if the response was already posted. - * 5. Resolve the T3 outcome and stop if the turn has not ended. - * 6. Post the response. - * 7. Record `ResponsePosted`. - * 8. Archive the T3 thread. - */ -declare const processT3Event: ( - event: OrchestrationEvent, -) => Effect.Effect; - /** * Creates an NTBS processor for one adapter. * @@ -268,16 +220,34 @@ export const makeNTBSProcessor =

( const getNow = DateTime.now.pipe(Effect.map(DateTime.formatIso)); - /** Keeps one lock per user message so its turn cannot return two final responses at once. */ - const responseLocks = new Map(); + /** Keeps each user message's lock until its final response is recorded and no caller uses it. */ + const responseLocks = new Map< + MessageId, + { + readonly semaphore: Semaphore.Semaphore; + callers: number; + responsePosted: boolean; + } + >(); + + const getResponseLock = (userMessageId: MessageId) => { + let lock = responseLocks.get(userMessageId); + if (lock === undefined) { + lock = { + semaphore: Semaphore.makeUnsafe(1), + callers: 0, + responsePosted: false, + }; + responseLocks.set(userMessageId, lock); + } + return lock; + }; - const getResponseLock = (userMessageId: MessageId): Semaphore.Semaphore => { - let semaphore = responseLocks.get(userMessageId); - if (semaphore === undefined) { - semaphore = Semaphore.makeUnsafe(1); - responseLocks.set(userMessageId, semaphore); + const markResponsePosted = (userMessageId: MessageId): void => { + const lock = responseLocks.get(userMessageId); + if (lock !== undefined) { + lock.responsePosted = true; } - return semaphore; }; /** @@ -287,18 +257,26 @@ export const makeNTBSProcessor =

( const ensureUniqueOutcome = ( userMessageId: MessageId, effect: Effect.Effect, - ): Effect.Effect => { - const semaphore = getResponseLock(userMessageId); - return semaphore.withPermit(effect).pipe( - Effect.tap(() => - Effect.sync(() => { - if (responseLocks.get(userMessageId) === semaphore) { - responseLocks.delete(userMessageId); - } - }), - ), - ); - }; + ): Effect.Effect => + Effect.suspend(() => { + const lock = getResponseLock(userMessageId); + lock.callers += 1; + + return lock.semaphore.withPermit(effect).pipe( + Effect.ensuring( + Effect.sync(() => { + lock.callers -= 1; + if ( + lock.callers === 0 && + lock.responsePosted && + responseLocks.get(userMessageId) === lock + ) { + responseLocks.delete(userMessageId); + } + }), + ), + ); + }); /** * Starts the first turn in an existing T3 thread. @@ -360,6 +338,83 @@ export const makeNTBSProcessor =

( .pipe(orFail(`Failed to interrupt T3 turn ${turnId}`)); }); + /** + * Reads the final outcome of the turn started by one NTBS user message. + * Returns `null` while that exact turn is still pending or running. + */ + const resolveT3Outcome = ( + threadId: ThreadId, + userMessageId: MessageId, + ): Effect.Effect< + { readonly threadId: ThreadId; readonly response: NTBSResponse } | null, + NTBSProcessorError + > => + Effect.gen(function* () { + const turns = yield* projectionTurnRepository + .listByThreadId({ threadId }) + .pipe(orFail(`Failed loading turns for T3 thread ${threadId}`)); + + const matchingTurns = turns.filter((turn) => turn.pendingMessageId === userMessageId); + const turn = matchingTurns[0]; + + if (matchingTurns.length !== 1 || turn === undefined) { + return yield* new NTBSProcessorError({ + reason: `Expected exactly one T3 turn for user message ${userMessageId}, found ${matchingTurns.length}.`, + cause: { threadId, userMessageId, matchingTurns }, + }); + } + + if (turn.state === "pending" || turn.state === "running") { + return null; + } + + const maybeThread = yield* projectionSnapshotQuery + .getThreadDetailById(threadId) + .pipe(orFail(`Failed loading T3 thread ${threadId}`)); + + const thread = yield* Effect.fromOption(maybeThread).pipe( + orFail(`Could not find T3 thread ${threadId}`), + ); + + if (turn.state === "completed") { + const assistantMessage = + turn.assistantMessageId === null + ? undefined + : thread.messages.find((message) => message.id === turn.assistantMessageId); + + const text = assistantMessage?.text.trim() ?? ""; + + return { + threadId, + response: + text.length > 0 + ? { type: "answer", text } + : { + type: "failure", + text: "T3 completed without producing a response.", + }, + }; + } + + if (turn.state === "error") { + return { + threadId, + response: { + type: "failure", + text: thread.session?.lastError ?? "T3 failed while processing this request.", + }, + }; + } + + return { + threadId, + response: { + type: "cancellation", + text: "T3 stopped processing this request.", + }, + }; + }); + /** * Posts one final response and records it in the adapter lifecycle. * @@ -389,14 +444,76 @@ export const makeNTBSProcessor =

( responseMessageId, }) .pipe(orFail("Failed recording the posted NTBS response")); + + markResponsePosted(threadCreated.t3Data.userMessageId); + }); + + /** + * Posts the final response when a T3 session event ends an NTBS turn. + * Other T3 events and threads unknown to this adapter are ignored. + */ + const processT3Event = (event: OrchestrationEvent): Effect.Effect => + Effect.gen(function* () { + if (event.type !== "thread.session-set") { + return; + } + + const threadId = event.payload.threadId; + + /* + We may receive events for threads that are not related to the current + platform, and thus, adapter. + So we check if the thread in question exists in the adapter records. + */ + const recordedThread = yield* adapter.findByThreadId(threadId).pipe( + Effect.catchTag("ThreadNotFound", () => Effect.succeed(null)), + orFail("Failed loading the NTBS lifecycle for a T3 event"), + ); + + if (recordedThread === null) { + return; + } + + /* + At the same time a thread may have different messages. We're only interested + in the last user message that appears in the adapter records. + */ + const userMessageId = recordedThread.t3Data.userMessageId; + + yield* ensureUniqueOutcome( + userMessageId, + Effect.gen(function* () { + // Timeout handling may have posted a response while this event was + // waiting for the same user message's outcome lock. + const currentRecord = yield* adapter.findByThreadId(threadId).pipe( + Effect.catchTag("ThreadNotFound", () => Effect.succeed(null)), + orFail("Failed reloading the NTBS lifecycle before posting its outcome"), + ); + + if (currentRecord === null) { + return; + } + + if (currentRecord.state === "thread.response.posted") { + markResponsePosted(userMessageId); + return; + } + + const outcome = yield* resolveT3Outcome(threadId, userMessageId); + if (outcome === null) { + return; + } + + yield* postResponse(currentRecord, outcome.response); + }), + ); }); /** * Stops a stalled T3 turn and reports the timeout to the external platform. * * Response handling is locked by user message so a normal completion and - * timeout cannot both post an outcome. Timed-out threads remain unarchived - * for inspection or manual retry. + * timeout cannot both post an outcome. */ const handleStalledTurn = ( userMessageId: MessageId, @@ -410,6 +527,7 @@ export const makeNTBSProcessor =

( .pipe(orFail("Failed loading the NTBS lifecycle for a stalled turn")); if (lifecycle.state === "thread.response.posted") { + markResponsePosted(userMessageId); return; } @@ -785,6 +903,83 @@ export const makeNTBSProcessor =

( return threadId; }); + /** + * Resumes one stored NTBS thread after the processor starts. + * + * Starts the original turn when it is missing, resumes monitoring while it + * is active, or posts its outcome when it already finished. + */ + const recoverThread = ( + threadCreated: NTBS.ThreadCreated

, + ): Effect.Effect => + Effect.gen(function* () { + const { threadId, userMessageId } = threadCreated.t3Data; + const turns = yield* projectionTurnRepository + .listByThreadId({ threadId }) + .pipe(orFail(`Failed loading turns while recovering T3 thread ${threadId}`)); + const matchingTurns = turns.filter((turn) => turn.pendingMessageId === userMessageId); + + if (matchingTurns.length > 1) { + return yield* new NTBSProcessorError({ + reason: `Expected at most one T3 turn for user message ${userMessageId}, found ${matchingTurns.length}.`, + cause: { threadId, userMessageId, matchingTurns }, + }); + } + + const turn = matchingTurns[0]; + + if (turn === undefined) { + yield* startT3Turn( + threadId, + userMessageId, + threadCreated.snapshot, + threadCreated.attachments, + ); + messageStatus.set(userMessageId, { + threadId, + recordedAt: yield* getNow, + stats: null, + }); + } else { + const status = yield* loadMessageStatus(userMessageId, threadId); + + if (status.stats !== null && status.stats.state !== "running") { + yield* ensureUniqueOutcome( + userMessageId, + Effect.gen(function* () { + const currentRecord = yield* adapter + .findByThreadId(threadId) + .pipe(orFail("Failed reloading the NTBS lifecycle during recovery")); + + if (currentRecord.state === "thread.response.posted") { + markResponsePosted(userMessageId); + return; + } + + const outcome = yield* resolveT3Outcome(threadId, userMessageId); + if (outcome !== null) { + yield* postResponse(currentRecord, outcome.response); + } + }), + ); + return; + } + + messageStatus.set(userMessageId, status); + } + + yield* monitorT3Turn(userMessageId).pipe( + Effect.catch((cause) => + Effect.logError("Recovered NTBS turn monitor failed", { + userMessageId, + threadId, + cause, + }), + ), + Effect.forkDetach, + ); + }); + /* Handles an external request in this order: @@ -862,7 +1057,17 @@ export const makeNTBSProcessor =

( ), Effect.forkDetach, ); - // Attempt to post the acknowledgement independently + + yield* adapter.postAcknowledgement(threadCreated).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed posting the NTBS acknowledgement", { + userMessageId, + threadId, + cause, + }), + ), + Effect.asVoid, + ); } return; }).pipe(Effect.ensuring(Effect.sync(() => inFlightRequests.delete(key)))); @@ -871,7 +1076,51 @@ export const makeNTBSProcessor =

( const process = (request: NTBS.NTBSInput

, t3Context: T3Context) => processAdapterRequest(request, t3Context); - const subscribeToT3Events = Effect.void; + const consumeT3Events = Stream.runForEach( + orchestrationEngineService.streamDomainEvents, + (event) => + processT3Event(event).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed processing T3 event for NTBS", { + eventType: event.type, + cause, + }), + ), + ), + ); + + const recoverStoredThreads = adapter.loadThreadsAwaitingResponse.pipe( + orFail("Failed loading NTBS threads awaiting a response"), + Effect.flatMap((threads) => + Effect.forEach( + threads, + (threadCreated) => + recoverThread(threadCreated).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed recovering an NTBS thread", { + threadId: threadCreated.t3Data.threadId, + userMessageId: threadCreated.t3Data.userMessageId, + cause, + }), + ), + ), + { discard: true }, + ), + ), + Effect.catch((cause) => + Effect.logError("Failed starting NTBS thread recovery", { + cause, + }), + ), + ); + + const subscribeToT3Events = Effect.scoped( + Effect.gen(function* () { + yield* consumeT3Events.pipe(Effect.forkScoped({ startImmediately: true })); + yield* recoverStoredThreads; + yield* Effect.never; + }), + ); return { process, diff --git a/docs/planning/ideas.md b/docs/planning/ideas.md index ead13d5f72e5..eb7d8431676d 100644 --- a/docs/planning/ideas.md +++ b/docs/planning/ideas.md @@ -32,6 +32,10 @@ The first NTBS adapters can run inside the T3 server, but some platform integrat When a remote adapter is implemented, either move its platform operations into the server or expose the processor and adapter operations through a network API. The shared lifecycle and storage design should not require every adapter to share the T3 server process. Choose the transport when the first remote adapter is ported. +## Decide thread archival after testing + +Keep NTBS-created T3 threads after their responses are posted for now. Once the workflow has been tested in practice, decide whether completed threads should be archived automatically and under which conditions. + ## Add worktree cleanup to the Jira bridge The Jira auto-create flow (`JiraIssueBridge.ts`) creates a worktree before dispatching From 5d99cee31d413e59fbe4796bd87c19605ab9817f Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 12 Aug 2026 23:29:11 +0200 Subject: [PATCH 051/110] feat: start processor testing --- apps/server/src/ntbs/processor.test.ts | 59 ++++++++++++++++++++++++++ apps/server/src/ntbs/processor.ts | 8 ++-- docs/planning/monitor-review.md | 25 ----------- 3 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/ntbs/processor.test.ts delete mode 100644 docs/planning/monitor-review.md diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts new file mode 100644 index 000000000000..a67d1289641d --- /dev/null +++ b/apps/server/src/ntbs/processor.test.ts @@ -0,0 +1,59 @@ +import { assert, describe, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Layer, Stream } from "effect"; +import { makeNTBSAdapterTag, ThreadNotFound } from "./adapter.ts"; +import type { PlatformData } from "./lifecycle.ts"; +import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; + +type TestData = PlatformData<{ messageId: string }, { responseMessageId: string }>; + +const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); + +const TestAdapterLive = Layer.succeed(TestAdapter, { + save: () => Effect.void, + postAcknowledgement: () => Effect.succeed("acknowledgement id"), + postResponse: () => Effect.succeed("response id"), + getRequestKey: () => "requestKey", + findByRequest: () => Effect.succeed(null), + findMatchingResponseMessage: () => Effect.succeed(null), + findByThreadId: () => new ThreadNotFound(), + loadThreadsAwaitingResponse: Effect.succeed([]), +}); + +const TestProcessor = makeNTBSProcessorTag("ntbs/TestProcessor"); + +/* + The processor's direct requirements are mocked one by one with `Layer.mock`: + a method left out simply dies if the test path reaches it, so each test only + fills in what it actually exercises. +*/ +const TestProcessorLive = Layer.effect(TestProcessor, makeNTBSProcessor(TestAdapter)).pipe( + Layer.provide(TestAdapterLive), + Layer.provide( + Layer.mock(OrchestrationEngineService)({ + streamDomainEvents: Stream.empty, + }), + ), + Layer.provide(Layer.mock(ProjectionSnapshotQuery)({})), + Layer.provide(Layer.mock(ProjectionTurnRepository)({})), + Layer.provide(Layer.mock(GitWorkflowService)({})), + Layer.provide(Layer.mock(ProjectSetupScriptRunner)({})), + // Provides Crypto (plus FileSystem/Path) for UUID generation. + Layer.provide(NodeServices.layer), +); + +describe("NTBSProcessor", () => { + it.effect("builds with mocked dependencies", () => + Effect.gen(function* () { + const processor = yield* TestProcessor; + + assert.isDefined(processor.process); + assert.isDefined(processor.subscribeToT3Events); + }).pipe(Effect.provide(TestProcessorLive)), + ); +}); diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index b0f5ea257c1c..3cc5ba798d52 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -203,10 +203,12 @@ type TurnStatus = { * * Resolves the required T3 services and returns processor operations with no remaining requirements. */ -export const makeNTBSProcessor =

( - adapter: NTBSAdapter

, -): Effect.Effect, never, NTBSProcessorRequirements> => +export const makeNTBSProcessor =

( + adapterTag: Context.Service>, +): Effect.Effect, never, AdapterId | NTBSProcessorRequirements> => Effect.gen(function* () { + const adapter = yield* adapterTag; + const orFail = (reason: string) => Effect.mapError((cause: unknown) => new NTBSProcessorError({ reason, cause })); diff --git a/docs/planning/monitor-review.md b/docs/planning/monitor-review.md deleted file mode 100644 index 36e2d80e53ad..000000000000 --- a/docs/planning/monitor-review.md +++ /dev/null @@ -1,25 +0,0 @@ -# NTBS turn monitor review - -The monitoring loop has a sound basic structure: it establishes a baseline, checks the projected turn repeatedly, resets its inactivity counter when observable progress appears, and stops when the turn reaches a terminal state. The following issues should be addressed before relying on it operationally. - -## Findings - -1. The monitor reads `thread.latestTurn`, not necessarily the original NTBS turn. If someone starts another turn in the same T3 thread, the monitor can silently switch targets. It should retain or recover the original `userMessageId` and use it to identify the correct turn. - -2. Multiple monitors for the same thread share and overwrite the same `threadStatus` entry. Their alternating checks could make inactivity accumulate too quickly. The processor should track which thread IDs already have an active monitor and refuse to start a duplicate. - -3. The stored status is removed only when a terminal turn is observed. It remains in memory when monitoring stops because of inactivity, failure, or interruption. Monitor termination should always remove the thread's entry from `threadStatus`. - -4. A temporary failure while reading the T3 projection terminates the monitor permanently. A failed check should be logged and retried on a later interval. It should not count as evidence that the turn made no progress. - -5. `loadThreadStatus` treats every missing `latestTurn` as a pending turn. It should verify that `pendingTurnStart` exists and belongs to the expected user message. If neither a pending request nor the expected turn exists, the projected T3 state is inconsistent and should produce an error. - -6. Six unchanged checks at 15-second intervals represent 90 seconds without observable progress, not the two minutes stated by the current log message. A two-minute threshold requires eight unchanged checks. - -7. The current stalled-turn branch only writes a debug log and stops monitoring. The T3 turn continues running without further supervision. The final implementation must apply the chosen stalled-turn policy, such as interrupting the turn and posting a timeout response. - -8. The progress heuristic cannot reliably observe buffered assistant output, hidden reasoning, or provider work that produces no projected event. A healthy turn may therefore appear unchanged. A short inactivity threshold increases the likelihood of false positives, so the initial threshold should be conservative and reviewed using real behavior. - -## Recommended first fix - -Track the original `userMessageId` when monitoring. Correctly identifying the intended turn is required before any progress comparison, timeout, or stalled-turn action can be trusted. From 35227a63adc4f30078925e9f260740751dc8ca41 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 13 Aug 2026 00:10:38 +0200 Subject: [PATCH 052/110] chore: more processor tests --- apps/server/src/ntbs/processor.test.ts | 135 +++++++++++++++++++------ 1 file changed, 105 insertions(+), 30 deletions(-) diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index a67d1289641d..951a2c5674d4 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -1,7 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import { NodeServices } from "@effect/platform-node"; -import { Effect, Layer, Stream } from "effect"; -import { makeNTBSAdapterTag, ThreadNotFound } from "./adapter.ts"; +import { + CommandId, + EventId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { DateTime, Deferred, Effect, Layer, PubSub, Stream } from "effect"; +import { makeNTBSAdapterTag, ThreadNotFound, type NTBSAdapter } from "./adapter.ts"; import type { PlatformData } from "./lifecycle.ts"; import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; @@ -14,46 +21,114 @@ type TestData = PlatformData<{ messageId: string }, { responseMessageId: string const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); -const TestAdapterLive = Layer.succeed(TestAdapter, { - save: () => Effect.void, - postAcknowledgement: () => Effect.succeed("acknowledgement id"), - postResponse: () => Effect.succeed("response id"), - getRequestKey: () => "requestKey", - findByRequest: () => Effect.succeed(null), - findMatchingResponseMessage: () => Effect.succeed(null), - findByThreadId: () => new ThreadNotFound(), - loadThreadsAwaitingResponse: Effect.succeed([]), +const makeTestAdapter = Effect.gen(function* () { + const eventReceived = yield* Deferred.make(); + + const service: NTBSAdapter = { + save: () => Effect.void, + postAcknowledgement: () => Effect.succeed("acknowledgement id"), + postResponse: () => Effect.succeed("response id"), + getRequestKey: () => "requestKey", + findByRequest: () => Effect.succeed(null), + findMatchingResponseMessage: () => Effect.succeed(null), + findByThreadId: (threadId) => + Effect.gen(function* () { + yield* Deferred.succeed(eventReceived, threadId); + return yield* new ThreadNotFound(); + }), + loadThreadsAwaitingResponse: Effect.succeed([]), + }; + + return { + eventReceived, + layer: Layer.succeed(TestAdapter, service), + }; }); const TestProcessor = makeNTBSProcessorTag("ntbs/TestProcessor"); +const makeTestOrchestrationEngine = Effect.gen(function* () { + const domainEvents = yield* PubSub.unbounded(); + const commands: OrchestrationCommand[] = []; + let sequence = 0; + + const service = OrchestrationEngineService.of({ + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: ++sequence }; + }), + streamDomainEvents: Stream.fromPubSub(domainEvents), + latestSequence: Effect.sync(() => sequence), + }); + + return { + layer: Layer.succeed(OrchestrationEngineService, service), + commands, + publish: (event: OrchestrationEvent) => PubSub.publish(domainEvents, event).pipe(Effect.asVoid), + }; +}); + /* The processor's direct requirements are mocked one by one with `Layer.mock`: a method left out simply dies if the test path reaches it, so each test only fills in what it actually exercises. */ -const TestProcessorLive = Layer.effect(TestProcessor, makeNTBSProcessor(TestAdapter)).pipe( - Layer.provide(TestAdapterLive), - Layer.provide( - Layer.mock(OrchestrationEngineService)({ - streamDomainEvents: Stream.empty, - }), - ), - Layer.provide(Layer.mock(ProjectionSnapshotQuery)({})), - Layer.provide(Layer.mock(ProjectionTurnRepository)({})), - Layer.provide(Layer.mock(GitWorkflowService)({})), - Layer.provide(Layer.mock(ProjectSetupScriptRunner)({})), - // Provides Crypto (plus FileSystem/Path) for UUID generation. - Layer.provide(NodeServices.layer), -); +const makeTestProcessorLive = ( + orchestrationEngine: Layer.Layer, + adapter: Layer.Layer>, +) => + Layer.effect(TestProcessor, makeNTBSProcessor(TestAdapter)).pipe( + Layer.provide(adapter), + Layer.provide(orchestrationEngine), + Layer.provide(Layer.mock(ProjectionSnapshotQuery)({})), + Layer.provide(Layer.mock(ProjectionTurnRepository)({})), + Layer.provide(Layer.mock(GitWorkflowService)({})), + Layer.provide(Layer.mock(ProjectSetupScriptRunner)({})), + // Provides Crypto (plus FileSystem/Path) for UUID generation. + Layer.provide(NodeServices.layer), + ); describe("NTBSProcessor", () => { - it.effect("builds with mocked dependencies", () => + it.effect("receives a T3 event", () => Effect.gen(function* () { - const processor = yield* TestProcessor; + const testEngine = yield* makeTestOrchestrationEngine; + const testAdapter = yield* makeTestAdapter; + const processor = yield* TestProcessor.pipe( + Effect.provide(makeTestProcessorLive(testEngine.layer, testAdapter.layer)), + ); + const threadId = ThreadId.make("someThread"); + const now = DateTime.formatIso(yield* DateTime.now); + + yield* processor.subscribeToT3Events.pipe(Effect.forkChild({ startImmediately: true })); - assert.isDefined(processor.process); - assert.isDefined(processor.subscribeToT3Events); - }).pipe(Effect.provide(TestProcessorLive)), + yield* testEngine.publish({ + type: "thread.session-set", + eventId: EventId.make("someEvent"), + occurredAt: now, + commandId: CommandId.make("someCommand"), + aggregateId: threadId, + aggregateKind: "thread", + sequence: 0, + causationEventId: EventId.make("someOtherEvent"), + correlationId: null, + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "running", + providerName: null, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + }); + + assert.equal(yield* Deferred.await(testAdapter.eventReceived), threadId); + }), ); }); From 9debc391816881784093e7aa6d76e5e98d944945 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 13 Aug 2026 14:43:40 +0200 Subject: [PATCH 053/110] chore: first step of processor testing --- apps/server/src/ntbs/processor.test.ts | 124 +++++++++++++++++++++---- apps/server/src/ntbs/test-helpers.ts | 69 ++++++++++++++ docs/planning/processor-testing.md | 25 +++++ 3 files changed, 201 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/ntbs/test-helpers.ts create mode 100644 docs/planning/processor-testing.md diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index 951a2c5674d4..30633431a533 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -3,6 +3,8 @@ import { NodeServices } from "@effect/platform-node"; import { CommandId, EventId, + OrchestrationProjectShell, + ProviderInstanceId, ThreadId, type OrchestrationCommand, type OrchestrationEvent, @@ -14,13 +16,16 @@ import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; -import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { createAdapterRequest, createGitLayerMock } from "./test-helpers.ts"; +import { some } from "effect/Option"; type TestData = PlatformData<{ messageId: string }, { responseMessageId: string }>; const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); +const createRequest = createAdapterRequest; + const makeTestAdapter = Effect.gen(function* () { const eventReceived = yield* Deferred.make(); @@ -70,6 +75,21 @@ const makeTestOrchestrationEngine = Effect.gen(function* () { }; }); +/* +processes a new request into a T3 thread, starts its first turn, persists lifecycle state, and posts an acknowledgement + + Assert that it: + + - fetches and resolves the requested base ref; + - creates the worktree; + - dispatches thread.create, then thread.turn.start; + - preserves the snapshot and attachments; + - saves thread.created with the generated thread/message IDs; + - runs setup; + - posts the acknowledgement; + - does not duplicate work. +*/ + /* The processor's direct requirements are mocked one by one with `Layer.mock`: a method left out simply dies if the test path reaches it, so each test only @@ -78,29 +98,79 @@ const makeTestOrchestrationEngine = Effect.gen(function* () { const makeTestProcessorLive = ( orchestrationEngine: Layer.Layer, adapter: Layer.Layer>, -) => - Layer.effect(TestProcessor, makeNTBSProcessor(TestAdapter)).pipe( - Layer.provide(adapter), - Layer.provide(orchestrationEngine), - Layer.provide(Layer.mock(ProjectionSnapshotQuery)({})), - Layer.provide(Layer.mock(ProjectionTurnRepository)({})), - Layer.provide(Layer.mock(GitWorkflowService)({})), - Layer.provide(Layer.mock(ProjectSetupScriptRunner)({})), - // Provides Crypto (plus FileSystem/Path) for UUID generation. - Layer.provide(NodeServices.layer), - ); +) => { + const gitLayer = createGitLayerMock(); + return { + layer: Layer.effect(TestProcessor, makeNTBSProcessor(TestAdapter)).pipe( + Layer.provide(adapter), + Layer.provide(orchestrationEngine), + Layer.provide( + Layer.mock(ProjectionSnapshotQuery)({ + getProjectShellById: (projectId) => + Effect.sync(() => { + return some( + OrchestrationProjectShell.make({ + createdAt: new Date().toISOString(), + id: projectId, + title: "project title", + workspaceRoot: "workspaceRoot", + defaultModelSelection: { + model: "gpt-does-not-exist-v2", + instanceId: ProviderInstanceId.make("gpt-does-not-exist-v2"), + }, + updatedAt: new Date().toISOString(), + scripts: [], + }), + ); + }), + }), + ), + Layer.provide(Layer.mock(ProjectionTurnRepository)({})), + Layer.provide(gitLayer.layer), + Layer.provide( + Layer.mock(ProjectSetupScriptRunner)({ + runForThread: (input) => + Effect.sync(() => { + return { status: "no-script" }; + }), + }), + ), + // Provides Crypto (plus FileSystem/Path) for UUID generation. + Layer.provide(NodeServices.layer), + ), + gitCalls: gitLayer.gitCalls, + }; +}; + +const createProcessor = Effect.gen(function* () { + const testEngine = yield* makeTestOrchestrationEngine; + const testAdapter = yield* makeTestAdapter; + + const processorLive = makeTestProcessorLive(testEngine.layer, testAdapter.layer); + + const processor = yield* TestProcessor.pipe(Effect.provide(processorLive.layer)); + return { + processor, + testEngine, + testAdapter, + gitCalls: processorLive.gitCalls, + }; +}); describe("NTBSProcessor", () => { it.effect("receives a T3 event", () => Effect.gen(function* () { - const testEngine = yield* makeTestOrchestrationEngine; - const testAdapter = yield* makeTestAdapter; - const processor = yield* TestProcessor.pipe( - Effect.provide(makeTestProcessorLive(testEngine.layer, testAdapter.layer)), - ); const threadId = ThreadId.make("someThread"); const now = DateTime.formatIso(yield* DateTime.now); + const { processor, testEngine, testAdapter, gitCalls } = yield* createProcessor; + + // Should have done no git operations after starting + assert.equal(gitCalls.createWorktree.length, 0); + assert.equal(gitCalls.fetchRemote.length, 0); + assert.equal(gitCalls.removeWorktree.length, 0); + assert.equal(gitCalls.resolveRemoteTrackingCommit.length, 0); + yield* processor.subscribeToT3Events.pipe(Effect.forkChild({ startImmediately: true })); yield* testEngine.publish({ @@ -129,6 +199,26 @@ describe("NTBSProcessor", () => { }); assert.equal(yield* Deferred.await(testAdapter.eventReceived), threadId); + + const request = createRequest({ + responseDestination: { + responseMessageId: "responseMessageId", + }, + source: { + messageId: "messageId", + }, + }); + + yield* processor.process(request.request, request.t3Context); + + // should have called to create a worktree + assert.strictEqual(gitCalls.createWorktree.length, 1); + // should have fetched a remote + assert.strictEqual(gitCalls.fetchRemote.length, 1); + // should have resolved the remote tracking commit + assert.strictEqual(gitCalls.resolveRemoteTrackingCommit.length, 1); + // no reason for removing the work tree, yet + assert.strictEqual(gitCalls.removeWorktree.length, 0); }), ); }); diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts new file mode 100644 index 000000000000..f9aa9ea35891 --- /dev/null +++ b/apps/server/src/ntbs/test-helpers.ts @@ -0,0 +1,69 @@ +import { Effect, Layer } from "effect"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectId, VcsCreateWorktreeResult } from "@t3tools/contracts"; +import type { NTBSInput, PlatformData } from "./lifecycle.ts"; +import type { T3Context } from "./processor.ts"; + +export const createGitLayerMock = () => { + const gitCalls = { + createWorktree: [] as unknown[], + fetchRemote: [] as unknown[], + resolveRemoteTrackingCommit: [] as unknown[], + removeWorktree: [] as unknown[], + }; + + const layer = Layer.mock(GitWorkflowService, { + fetchRemote: (input) => + Effect.sync(function () { + gitCalls.fetchRemote.push(input); + }), + resolveRemoteTrackingCommit: (input) => + Effect.sync(() => { + gitCalls.resolveRemoteTrackingCommit.push(input); + + return { commitSha: "input-sha", remoteRefName: input.refName }; + }), + createWorktree: (input) => + Effect.sync(() => { + gitCalls.createWorktree.push(input); + return VcsCreateWorktreeResult.make({ + worktree: { + path: "createworktreepath", + refName: input.refName, + }, + }); + }), + removeWorktree: (input) => + Effect.sync(() => { + gitCalls.removeWorktree.push(input); + + return; + }), + }); + + return { + gitCalls, + layer, + }; +}; + +export const createAdapterRequest = (input: { + responseDestination: Destination; + source: Source; +}): { + request: NTBSInput>; + t3Context: T3Context; +} => ({ + request: { + snapshot: "This is an ongoing discussion", + attachments: [], + platformData: { + responseDestination: input.responseDestination, + source: input.source, + }, + }, + t3Context: { + baseRef: "fork/dev", + projectId: ProjectId.make("project"), + }, +}); diff --git a/docs/planning/processor-testing.md b/docs/planning/processor-testing.md new file mode 100644 index 000000000000..51e602df40c6 --- /dev/null +++ b/docs/planning/processor-testing.md @@ -0,0 +1,25 @@ +# Goal 1 - Happy path testing + +## Step 1 - it fetches and resolves the requested base ref + +We test this indirectly via `processor.process(request, {projectId, baseRef: "branchname" })`. + +For a new request, `process()` calls `createT3Thread`, which should: + +1. fetch `branchname` +2. resolve `branchname` against the remote tracking branch +3. pass the resolved commit SHA into `createWorktree` + +## Step 2 - it creates the isolated worktree + +## Step 3 - dispatches `thread.create` and then `thread.turn.start` + +## Step 4 - preserves the snapshot and attachments + +## Step 5 - saves `thread.created` with generated thread and message IDs. + +## Step 6 - runs the project setup script + +## Step 7 - posts the acknowledgement + +## Step 8 - does not duplicate work From 3b6e6c62ae25e20ed037015f07929ba3dc924a8b Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 13 Aug 2026 14:56:42 +0200 Subject: [PATCH 054/110] chore: add deferred idea to ideas.md --- docs/planning/ideas.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/planning/ideas.md b/docs/planning/ideas.md index eb7d8431676d..cda99daaed6a 100644 --- a/docs/planning/ideas.md +++ b/docs/planning/ideas.md @@ -64,3 +64,11 @@ If the ref noise ever matters, the shape is: with its own log warning. - Comment on the service op why this exception to branch retention exists, so it is not "harmonized" with `cleanupThreadWorktree`'s keep-the-branch behavior. + +## Use Deferred for asynchronous test synchronization + +Effect's `Deferred` is useful as a one-shot, promise-like latch when a test needs to wait for an +asynchronous operation to reach a specific point. The code under test completes it, while the test +awaits it deterministically, avoiding arbitrary sleeps, flaky timing assumptions, and unnecessary +polling. Use it to coordinate milestones such as a subscriber consuming an event; direct +`processor.process` tests generally do not need it. From d5e037b05164edd04a94fe18bd84a30134023422 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 13 Aug 2026 22:28:57 +0200 Subject: [PATCH 055/110] feat: simplify adapter --- apps/server/src/ntbs/adapter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 5ae7bf60e4ca..23ba92f78830 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -93,7 +93,6 @@ export interface NTBSAdapter

{ */ readonly findMatchingResponseMessage: ( state: NTBS.ThreadCreated

, - response: NTBSResponse, ) => Effect.Effect; /** * Finds the latest lifecycle state associated with a T3 thread. From 509e6551e9c629ada4491164eba459796008a82a Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 13 Aug 2026 22:29:18 +0200 Subject: [PATCH 056/110] chore: add new idea --- docs/planning/ideas.md | 59 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/planning/ideas.md b/docs/planning/ideas.md index cda99daaed6a..a7a4546d39b3 100644 --- a/docs/planning/ideas.md +++ b/docs/planning/ideas.md @@ -8,6 +8,26 @@ For now, the shared lifecycle should begin with `ThreadCreated`. The processor s A stronger recovery system can be added later if real usage requires it. Each adapter could inspect recent messages on its platform, find requests that have no corresponding T3 thread, and submit them again. This belongs to the adapter because Jira, GitHub, Discord, and Teams provide different ways to read their recent messages. +## Durable request dedup is necessary yet insufficient + +The `adapter.findByRequest` check at the start of `processAdapterRequest` (`processor.ts`) cannot be +removed. It is the only durable dedup in the pipeline: `inFlightRequests` is in-memory, covers only +concurrent deliveries inside one process, and is cleared the moment a request finishes or the server +restarts. The source platforms deliver at-least-once (webhook retries, Discord gateway replays), so +without this check a late redelivery would create a second worktree, thread, and turn, and post a +second final response. Deferring the check to a uniqueness conflict in `adapter.save` would be +worse, because the conflict would surface only after the expensive thread provisioning already ran. + +The check is still insufficient on its own. It is check-then-act against the adapter store, and +between `createT3Thread` and the `adapter.save` of `ThreadCreated` there is a crash window where no +record exists yet: a redelivery after a crash there passes `findByRequest` and provisions a +duplicate thread. The fix, if real usage ever needs it, is not another read but an atomic +insert-if-absent reservation keyed by `getRequestKey` before thread creation. That is the same +tradeoff already described in "Keep the shared lifecycle small": a pre-thread lifecycle state plus +recovery for stale reservations. Defer it until a production adapter observes redelivery during a +crash; the dedup behavior itself is pinned by the processor test that drops a redelivered request +with a recorded thread. + ## Remove acknowledgement from the shared lifecycle The acknowledgement is platform feedback, such as a "working on it" message. It should not be a required stage in the shared NTBS lifecycle because failing to post it, or failing to save its message ID, must not prevent the processor from representing and posting the final response. @@ -20,6 +40,35 @@ The shared sequence becomes: The processor does not use acknowledgement success as a condition for continuing. Posting may happen alongside the start of T3 work, and an adapter may retry a failed acknowledgement, but the final answer always remains tied to the original response destination. If a platform benefits from replying to the acknowledgement, its adapter can use the identifier stored in its own data without adding that dependency to the shared lifecycle. +## Persist response intent before posting (outbox pattern) + +`postResponse` in `processor.ts` posts the final response to the platform and then saves +`ResponsePosted`. Because the post targets an external platform and the save targets the local +store, no transaction can span both, and compensation (deleting the posted message when the save +fails) cannot close the hole either: the failure mode that matters is process death between post +and save, and a dead process runs no compensation. Deleting an already-read correct answer because +a local write failed is also worse UX than retrying the save, and some adapters (Jira comments, +restricted channels) may lack delete permission entirely. + +Today that crash window is covered by `findMatchingResponseMessage`, which probes the platform by +response content on every post. Content is the wrong identity test: the recomputed outcome can +drift across restarts (a posted timeout resolves as a cancellation after recovery interrupts the +turn; the `error` branch depends on `session.lastError`), so the probe misses the earlier response +and a second, differently-worded final response gets posted. + +The fix is to persist intent before posting: + +1. Save a `thread.response.posting` state carrying the response payload (type + text). +2. Post to the platform. +3. Save `thread.response.posted` with the platform message ID. + +This gives recovery precision (only records stuck in `response.posting` may have an unrecorded +post — `thread.created` records are known-unposted and need no platform search), removes the drift +bug (recovery reposts the stored text instead of recomputing the outcome), and makes a failed +step-3 save trivially retryable. The platform probe shrinks to a rarely-exercised recovery path, +and its contract should be "any final response this adapter already posted for this request" +(`findResponseMessage(state)`, no `response` parameter) rather than content matching. + ## Remove fork-specific provenance after the NTBS migration Keep `SourceChannel`, `SourceRef`, `sourceHint`, `originSource`, and related fork-specific provenance out of the NTBS design. Adapters already retain the platform data needed to connect external messages with T3 work. @@ -72,3 +121,13 @@ asynchronous operation to reach a specific point. The code under test completes awaits it deterministically, avoiding arbitrary sleeps, flaky timing assumptions, and unnecessary polling. Use it to coordinate milestones such as a subscriber consuming an event; direct `processor.process` tests generally do not need it. + +## Start the T3 event subscription with the processor + +Processors should subscribe to T3 events automatically as part of their managed startup +lifecycle, rather than exposing `subscribeToT3Events` for callers to invoke. The public processor +API should focus on business operations such as `process`; the subscription and stored-thread +recovery should start when the processor layer is provided and stop with its application scope. +Use a scoped resource or layer so the background fiber is owned, interruptible, and cannot be +accidentally started twice by callers. Tests should construct the live processor, publish an event, +and assert the observable result without manually starting the subscription. From 829b9b62146e535f03c7e0d38b3551e815f07b00 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 13 Aug 2026 22:29:54 +0200 Subject: [PATCH 057/110] chore: update processor to new api --- apps/server/src/ntbs/processor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 3cc5ba798d52..b1e88cb4b565 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -430,7 +430,7 @@ export const makeNTBSProcessor =

( ): Effect.Effect => Effect.gen(function* () { const existingResponseMessageId = yield* adapter - .findMatchingResponseMessage(threadCreated, response) + .findMatchingResponseMessage(threadCreated) .pipe(orFail("Failed checking whether the NTBS response was already posted")); const responseMessageId = From 75e6521dfd4ac93df0dc9f92678688cc5b6f0ed4 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 13 Aug 2026 23:11:33 +0200 Subject: [PATCH 058/110] feat: more work on ntbs test helpers --- apps/server/src/ntbs/processor.test.ts | 76 ++---- apps/server/src/ntbs/processor.ts | 3 +- apps/server/src/ntbs/processor2.test.ts | 293 ++++++++++++++++++++++++ apps/server/src/ntbs/test-helpers.ts | 218 +++++++++++++++++- 4 files changed, 523 insertions(+), 67 deletions(-) create mode 100644 apps/server/src/ntbs/processor2.test.ts diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index 30633431a533..aef3361dbba9 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -17,14 +17,20 @@ import { OrchestrationEngineService } from "../orchestration/Services/Orchestrat import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; -import { createAdapterRequest, createGitLayerMock } from "./test-helpers.ts"; +import { createAdapterRequest, createGitLayerMock, type TestData } from "./test-helpers.ts"; import { some } from "effect/Option"; -type TestData = PlatformData<{ messageId: string }, { responseMessageId: string }>; - const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); -const createRequest = createAdapterRequest; +const createRequest = (responseId: string, sourceId: string) => + createAdapterRequest({ + responseDestination: { + responseMessageId: responseId, + }, + source: { + messageId: sourceId, + }, + }); const makeTestAdapter = Effect.gen(function* () { const eventReceived = yield* Deferred.make(); @@ -157,68 +163,14 @@ const createProcessor = Effect.gen(function* () { }; }); -describe("NTBSProcessor", () => { +describe("Basic happy case", () => { it.effect("receives a T3 event", () => Effect.gen(function* () { - const threadId = ThreadId.make("someThread"); - const now = DateTime.formatIso(yield* DateTime.now); - - const { processor, testEngine, testAdapter, gitCalls } = yield* createProcessor; - - // Should have done no git operations after starting - assert.equal(gitCalls.createWorktree.length, 0); - assert.equal(gitCalls.fetchRemote.length, 0); - assert.equal(gitCalls.removeWorktree.length, 0); - assert.equal(gitCalls.resolveRemoteTrackingCommit.length, 0); - - yield* processor.subscribeToT3Events.pipe(Effect.forkChild({ startImmediately: true })); - - yield* testEngine.publish({ - type: "thread.session-set", - eventId: EventId.make("someEvent"), - occurredAt: now, - commandId: CommandId.make("someCommand"), - aggregateId: threadId, - aggregateKind: "thread", - sequence: 0, - causationEventId: EventId.make("someOtherEvent"), - correlationId: null, - metadata: {}, - payload: { - threadId, - session: { - threadId, - status: "running", - providerName: null, - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }, - }); - - assert.equal(yield* Deferred.await(testAdapter.eventReceived), threadId); - - const request = createRequest({ - responseDestination: { - responseMessageId: "responseMessageId", - }, - source: { - messageId: "messageId", - }, - }); + const { processor } = yield* createProcessor; - yield* processor.process(request.request, request.t3Context); + const request = createRequest("responseId", "sourceIds"); - // should have called to create a worktree - assert.strictEqual(gitCalls.createWorktree.length, 1); - // should have fetched a remote - assert.strictEqual(gitCalls.fetchRemote.length, 1); - // should have resolved the remote tracking commit - assert.strictEqual(gitCalls.resolveRemoteTrackingCommit.length, 1); - // no reason for removing the work tree, yet - assert.strictEqual(gitCalls.removeWorktree.length, 0); + yield* processor.process(request.request, request.t3Context); }), ); }); diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index b1e88cb4b565..dbd565e42f11 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -14,8 +14,7 @@ import { type TurnId, } from "@t3tools/contracts"; import type * as NTBS from "./lifecycle.ts"; -import { Context, Crypto, Data, DateTime, Effect, Schedule, Stream } from "effect"; -import * as Semaphore from "effect/Semaphore"; +import { Context, Crypto, Data, DateTime, Effect, Schedule, Stream, Semaphore } from "effect"; import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts new file mode 100644 index 000000000000..1f37c43745a7 --- /dev/null +++ b/apps/server/src/ntbs/processor2.test.ts @@ -0,0 +1,293 @@ +import { assert, describe, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { Context, DateTime, Effect, Layer, PubSub, Queue, Stream } from "effect"; +import { + makeNTBSAdapterTag, + ThreadNotFound, + type NTBSAdapter, + type NTBSResponse, +} from "./adapter.ts"; +import type { NTBSInput, NTBSLifecycle, PlatformData, ThreadCreated } from "./lifecycle.ts"; +import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; + +type TestData = PlatformData<{ messageId: string }, { responseMessageId: string }>; + +/* + Harness layout: + + The fakes' observable state lives in dedicated context services (TestEngine, + TestAdapterState). The real service tags (OrchestrationEngineService, the + adapter) get thin layers derived from that state. `Layer.provideMerge` keeps + the state services visible to the tests, so a test pulls its handles from + context instead of building fakes inline and closing over them. + + Everything is provided per test with `Effect.provide(Harness)`: the processor + holds internal mutable state (dedup keys, outcome locks, monitor baselines), + so a shared `layer(...)` block would leak state across tests. +*/ + +class TestEngine extends Context.Service< + TestEngine, + { + /** Every command the processor dispatched, in order. */ + readonly commands: Array; + /** Emits a domain event as if the orchestration engine produced it. */ + readonly publish: (event: OrchestrationEvent) => Effect.Effect; + readonly domainEvents: PubSub.PubSub; + } +>()("test/ntbs/TestEngine") { + static readonly layer = Layer.effect( + TestEngine, + Effect.gen(function* () { + const domainEvents = yield* PubSub.unbounded(); + return { + commands: [], + domainEvents, + publish: (event: OrchestrationEvent) => + PubSub.publish(domainEvents, event).pipe(Effect.asVoid), + }; + }), + ); +} + +const OrchestrationEngineFromTestEngine = Layer.effect( + OrchestrationEngineService, + Effect.gen(function* () { + const engine = yield* TestEngine; + let sequence = 0; + + return OrchestrationEngineService.of({ + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + engine.commands.push(command); + return { sequence: ++sequence }; + }), + streamDomainEvents: Stream.fromPubSub(engine.domainEvents), + latestSequence: Effect.sync(() => sequence), + }); + }), +); + +class TestAdapterState extends Context.Service< + TestAdapterState, + { + /** Lifecycle records keyed by T3 thread — seed before acting, inspect after. */ + readonly records: Map>; + readonly postedAcks: Array>; + readonly postedResponses: Array<{ + readonly record: ThreadCreated; + readonly response: NTBSResponse; + }>; + /** One entry per findByThreadId call; taking from it awaits event delivery. */ + readonly threadLookups: Queue.Queue; + } +>()("test/ntbs/TestAdapterState") { + static readonly layer = Layer.effect( + TestAdapterState, + Effect.gen(function* () { + return { + records: new Map>(), + postedAcks: [], + postedResponses: [], + threadLookups: yield* Queue.unbounded(), + }; + }), + ); +} + +const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); + +const AdapterFromState = Layer.effect( + TestAdapter, + Effect.gen(function* () { + const state = yield* TestAdapterState; + + const adapter: NTBSAdapter = { + save: (lifecycleEvent) => + Effect.sync(() => { + state.records.set(lifecycleEvent.t3Data.threadId, lifecycleEvent); + }), + postAcknowledgement: (record) => + Effect.sync(() => { + state.postedAcks.push(record); + return `ack-${state.postedAcks.length}`; + }), + postResponse: (record, response) => + Effect.sync(() => { + state.postedResponses.push({ record, response }); + return `response-${state.postedResponses.length}`; + }), + getRequestKey: (request) => request.platformData.source.messageId, + findByRequest: (request) => + Effect.sync( + () => + [...state.records.values()].find( + (record) => + record.platformData.source.messageId === request.platformData.source.messageId, + ) ?? null, + ), + findMatchingResponseMessage: () => Effect.succeed(null), + findByThreadId: (threadId) => + Queue.offer(state.threadLookups, threadId).pipe( + Effect.flatMap(() => { + const record = state.records.get(threadId); + return record === undefined + ? Effect.fail(new ThreadNotFound()) + : Effect.succeed(record); + }), + ), + loadThreadsAwaitingResponse: Effect.sync(() => + [...state.records.values()].filter( + (record): record is ThreadCreated => record.state === "thread.created", + ), + ), + }; + + return adapter; + }), +); + +const TestProcessor = makeNTBSProcessorTag("ntbs/TestProcessor"); + +const Harness = Layer.effect(TestProcessor, makeNTBSProcessor(TestAdapter)).pipe( + Layer.provide(AdapterFromState), + Layer.provide(OrchestrationEngineFromTestEngine), + Layer.provideMerge(TestAdapterState.layer), + Layer.provideMerge(TestEngine.layer), + Layer.provide(Layer.mock(ProjectionSnapshotQuery)({})), + Layer.provide(Layer.mock(ProjectionTurnRepository)({})), + Layer.provide(Layer.mock(GitWorkflowService)({})), + Layer.provide(Layer.mock(ProjectSetupScriptRunner)({})), + // Provides Crypto (plus FileSystem/Path) for UUID generation. + Layer.provide(NodeServices.layer), +); + +const sessionSetEvent = (threadId: ThreadId): Effect.Effect => + Effect.map(DateTime.now, (nowDateTime) => { + const now = DateTime.formatIso(nowDateTime); + return { + type: "thread.session-set", + eventId: EventId.make(`event-for-${threadId}`), + occurredAt: now, + commandId: CommandId.make("someCommand"), + aggregateId: threadId, + aggregateKind: "thread", + sequence: 0, + causationEventId: EventId.make("someOtherEvent"), + correlationId: null, + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "running", + providerName: null, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + }; + }); + +const makeRequest = (platformMessageId: string): NTBSInput => ({ + platformData: { + source: { messageId: platformMessageId }, + responseDestination: { responseMessageId: "destination" }, + }, + snapshot: "please look into this", + attachments: [], +}); + +const recordedThread = ( + request: NTBSInput, + threadId: ThreadId, +): ThreadCreated => ({ + ...request, + state: "thread.created", + t3Data: { threadId, userMessageId: MessageId.make(`message-for-${threadId}`) }, +}); + +describe("NTBSProcessor (layer harness)", () => { + it.effect("delivers T3 session events to the adapter and ignores unknown threads", () => + Effect.gen(function* () { + const engine = yield* TestEngine; + const adapterState = yield* TestAdapterState; + const processor = yield* TestProcessor; + + yield* processor.subscribeToT3Events.pipe(Effect.forkChild({ startImmediately: true })); + + const threadId = ThreadId.make("unknown-thread"); + yield* engine.publish(yield* sessionSetEvent(threadId)); + + // Taking the recorded lookup proves the event crossed the stream into + // the adapter; an unknown thread must produce no further activity. + assert.strictEqual(yield* Queue.take(adapterState.threadLookups), threadId); + assert.deepStrictEqual(adapterState.postedResponses, []); + assert.deepStrictEqual(engine.commands, []); + }).pipe(Effect.provide(Harness)), + ); + + it.effect("drops a redelivered request that already has a recorded thread", () => + Effect.gen(function* () { + const engine = yield* TestEngine; + const adapterState = yield* TestAdapterState; + const processor = yield* TestProcessor; + + const request = makeRequest("platform-message-1"); + const threadId = ThreadId.make("existing-thread"); + adapterState.records.set(threadId, recordedThread(request, threadId)); + + yield* processor.process(request, { + projectId: ProjectId.make("some-project"), + baseRef: "main", + }); + + // Durable dedup: no new thread or turn, no second acknowledgement. + assert.deepStrictEqual(engine.commands, []); + assert.deepStrictEqual(adapterState.postedAcks, []); + }).pipe(Effect.provide(Harness)), + ); + + it.effect("records an already-posted response without posting again", () => + Effect.gen(function* () { + const engine = yield* TestEngine; + const adapterState = yield* TestAdapterState; + const processor = yield* TestProcessor; + + const request = makeRequest("platform-message-2"); + const threadId = ThreadId.make("answered-thread"); + adapterState.records.set(threadId, { + ...recordedThread(request, threadId), + state: "thread.response.posted", + responseMessageId: "already-posted", + }); + + yield* processor.subscribeToT3Events.pipe(Effect.forkChild({ startImmediately: true })); + yield* engine.publish(yield* sessionSetEvent(threadId)); + + // The processor loads the record twice: once to route the event and once + // under the outcome lock before deciding what to post. + yield* Queue.take(adapterState.threadLookups); + yield* Queue.take(adapterState.threadLookups); + + assert.deepStrictEqual(adapterState.postedResponses, []); + }).pipe(Effect.provide(Harness)), + ); +}); diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts index f9aa9ea35891..8f8cacb651a2 100644 --- a/apps/server/src/ntbs/test-helpers.ts +++ b/apps/server/src/ntbs/test-helpers.ts @@ -1,8 +1,16 @@ -import { Effect, Layer } from "effect"; +import { Context, Effect, Layer, PubSub, Queue, Stream } from "effect"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; -import { ProjectId, VcsCreateWorktreeResult } from "@t3tools/contracts"; -import type { NTBSInput, PlatformData } from "./lifecycle.ts"; +import { + OrchestrationCommand, + OrchestrationEvent, + ProjectId, + ThreadId, + VcsCreateWorktreeResult, +} from "@t3tools/contracts"; +import type { NTBSInput, NTBSLifecycle, PlatformData, ThreadCreated } from "./lifecycle.ts"; import type { T3Context } from "./processor.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { makeNTBSAdapterTag, ThreadNotFound, type NTBSResponse } from "./adapter.ts"; export const createGitLayerMock = () => { const gitCalls = { @@ -67,3 +75,207 @@ export const createAdapterRequest = (input: { projectId: ProjectId.make("project"), }, }); + +export type TestData = PlatformData<{ messageId: string }, { responseMessageId: string }>; + +/* + # Testing strategy + + At its core the processor provides the business logic implementation that interacts with external services. + + The two core *behavioral* boundaries that are coordinated by the processor are the: + + - T3 orchestration engine. Emits event that the processor reads, receives commands (such as `thread.create` or `thread.turn.start` from the processor. + + - Adapter. The software responsible for the external platform (such as Jira or Discord) integration. It records acknowledgements and responses, stores lifecycle records, and answers deduplication and thread lookup queries. + + The other boundaries that communicate with the processor are: + - ProjectionSnapshotQuery: reads project and thread state + - ProjectionTurnRepository: reads turn progress + - GitWorkflowService: fetches refs and creates worktrees + - ProjectSetupScriptRunner: runs project setup + + Focusing on the behavioral boundary allows to quickly test the happy cases. + + The test simulates T3 events entering the processor, and the commands that the processor sends to T3 via `TestT3Engine`. + + It inspects the acknowledgements, responses and lifecycle events of the adapter via `TestAdapter`. +*/ + +class TestEngine extends Context.Service< + TestEngine, + { + /** + * Every command the processor dispatched, in order. + */ + readonly commandsReceived: Array; + /** + * Emits a domain event as if the orchestration engine produced it. + */ + readonly publish: (event: OrchestrationEvent) => Effect.Effect; + + readonly domainEvents: PubSub.PubSub; + } +>()("test/ntbs/TestEngine") { + static readonly layer = Layer.effect( + TestEngine, + Effect.gen(function* () { + /** + * In production `OrchestrationEngineService.streamDomainEvents` is the live + * feed of domain events the engine emits as it processes commands. + * + * It is a Stream. + * + * Here, `domainEvents` is the PubSub where events are published. + * + * The main purpose of this PubSub and related code is enabling tests to say "the engine just emitted event X for thread Y" without any real engine, persistence or provider session existing. + */ + const domainEvents = yield* PubSub.unbounded(); + return { + commandsReceived: [], + domainEvents, + /** + * The entry point for emulating orchestration engine published events in test. + * + * Call `TestEngine.publish`. + * It will publish the event to `domainEvents`. + * Then, the `OrchestrationEngineService` will stream that event out of `streamDomainEvents`, in the very same fashion the + */ + publish: (event: OrchestrationEvent) => PubSub.publish(domainEvents, event), + }; + }), + ); +} + +const OrchestrationEngineFromTestEngine = Layer.effect( + OrchestrationEngineService, + Effect.gen(function* () { + const engine = yield* TestEngine; + let sequence = 0; + + return OrchestrationEngineService.of({ + dispatch: (command) => + Effect.sync(() => { + engine.commandsReceived.push(command); + sequence += 1; + return { sequence }; + }), + latestSequence: Effect.sync(() => sequence), + readEvents: () => Stream.empty, + streamDomainEvents: Stream.fromPubSub(engine.domainEvents), + }); + }), +); + +// TODO: Continue from here +class TestAdapterState extends Context.Service< + TestAdapterState, + { + /** + * Lifecycle records keyed by T3 thread. + */ + readonly records: Map>; + // readonly lifecycleEvents: Array>; + readonly postedAcks: Map>; + readonly postedResponses: Map< + string, + { + readonly record: ThreadCreated; + readonly response: NTBSResponse; + } + >; + /** + * One entry per findByThreadId call; + * taking from it awaits event delivery. + */ + readonly threadLookups: Queue.Queue; + } +>()("test/ntbs/TestAdapterState") { + static readonly layer = Layer.effect( + TestAdapterState, + Effect.gen(function* () { + return { + // lifecycleEvents: [], + records: new Map>(), + postedAcks: new Map>(), + postedResponses: new Map< + string, + { + readonly record: ThreadCreated; + readonly response: NTBSResponse; + } + >(), + threadLookups: yield* Queue.unbounded(), + }; + }), + ); +} + +const TestAdapter = makeNTBSAdapterTag("test/ntbs/TestAdapter"); + +const TestAdapterFromState = Layer.effect( + TestAdapter, + Effect.gen(function* () { + const adapterState = yield* TestAdapterState; + + return { + save: (event) => + Effect.sync(() => { + adapterState.records.set(event.t3Data.threadId, event); + }), + postAcknowledgement: (state) => + Effect.sync(() => { + const acknowledgementId = `acknowledgementId-${adapterState.postedAcks.size}`; + adapterState.postedAcks.set(acknowledgementId, state); + return acknowledgementId; + }), + + postResponse: (state, response) => + Effect.sync(() => { + const messageId = `messageid-${adapterState.postedResponses.size}`; + adapterState.postedResponses.set(messageId, { + record: state, + response, + }); + return messageId; + }), + findByRequest: (request) => + Effect.sync(function () { + return ( + adapterState.records + .entries() + .map((el) => el[1]) + .find((entry) => { + return ( + entry.platformData.source.messageId === request.platformData.source.messageId + ); + }) ?? null + ); + }), + findByThreadId: (threadId) => + Effect.suspend(() => { + const maybeRecord = adapterState.records.get(threadId); + + return maybeRecord ? Effect.succeed(maybeRecord) : new ThreadNotFound(); + }), + findMatchingResponseMessage: (state) => + Effect.sync(() => { + const messageId = state.platformData.source.messageId; + const maybeResponse = adapterState.postedResponses + .entries() + .find(([_id, posted]) => posted.record.platformData.source.messageId === messageId); + return maybeResponse ? maybeResponse[0] : null; + }), + getRequestKey: (request) => request.platformData.source.messageId, + loadThreadsAwaitingResponse: Effect.sync(() => { + const awaitingResponse: ThreadCreated[] = []; + adapterState.records.forEach((state) => { + if (state.state === "thread.created") { + awaitingResponse.push(state); + } + }); + return awaitingResponse; + }), + }; + }), +); From f2179ae99b9eebe04c997bbcbd361bb94b4270a2 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 00:45:42 +0200 Subject: [PATCH 059/110] chore: sourceUris improvements --- apps/server/src/ntbs/adapter.ts | 42 ++++++---------------- apps/server/src/ntbs/lifecycle.ts | 46 ++++++++++++++----------- apps/server/src/ntbs/processor.test.ts | 30 +++++----------- apps/server/src/ntbs/processor.ts | 25 +++++++------- apps/server/src/ntbs/processor2.test.ts | 39 ++++++++------------- apps/server/src/ntbs/test-helpers.ts | 43 +++++++++-------------- 6 files changed, 85 insertions(+), 140 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 23ba92f78830..48e4ac9b3e96 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -29,20 +29,18 @@ export type NTBSResponse = { * * It does not create T3 threads or interpret T3 events. */ -export interface NTBSAdapter

{ +export interface NTBSAdapter { /** * Stores a lifecycle state. Does not perform any other business logic. */ - readonly save: (lifecycleEvent: NTBS.NTBSLifecycle

) => Effect.Effect; + readonly save: (lifecycleEvent: NTBS.NTBSLifecycle) => Effect.Effect; /** * Posts the working acknowledgement at the response destination, * described by the event. * * Returns the platform's identifier for the posted message. */ - readonly postAcknowledgement: ( - state: NTBS.ThreadCreated

, - ) => Effect.Effect; + readonly postAcknowledgement: (state: NTBS.ThreadCreated) => Effect.Effect; /** * Posts the final T3 outcome at the response destination described * by the event. @@ -51,39 +49,20 @@ export interface NTBSAdapter

{ * The processor uses that identifier to save `ResponsePosted`. */ readonly postResponse: ( - state: NTBS.ThreadCreated

, + state: NTBS.ThreadCreated, response: NTBSResponse, ) => Effect.Effect; - /** - * Derives the stable identity of a platform request. - * - * The same platform request must always produce the same key, - * across redeliveries and restarts. Distinct requests must produce - * distinct keys. - * - * This is the same identity `findByRequest` looks up, typically the - * platform's own message or event ID, e.g. a Jira comment ID or a - * Discord message ID. - * - * The processor uses it to serialize concurrent deliveries of the - * same request. It is also the natural unique key for the adapter's - * stored lifecycle records. - */ - readonly getRequestKey: (request: NTBS.NTBSInput

) => string; - /** * Finds lifecycle data already recorded for this platform request. * - * The adapter identifies the request using its platform-specific - * source data. * Returns `null` when no T3 thread has been recorded and processing * may continue. * Any lifecycle state means the request already has a T3 thread. */ readonly findByRequest: ( - request: NTBS.NTBSInput

, - ) => Effect.Effect | null, AdapterError>; + request: NTBS.NTBSInput, + ) => Effect.Effect; /** * Searches the response destination for a matching response previously * posted by this adapter. @@ -92,7 +71,7 @@ export interface NTBSAdapter

{ * message exists. */ readonly findMatchingResponseMessage: ( - state: NTBS.ThreadCreated

, + state: NTBS.ThreadCreated, ) => Effect.Effect; /** * Finds the latest lifecycle state associated with a T3 thread. @@ -102,16 +81,15 @@ export interface NTBSAdapter

{ */ readonly findByThreadId: ( threadId: ThreadId, - ) => Effect.Effect, ThreadNotFound | AdapterError>; + ) => Effect.Effect; /** * Loads records that reached `ThreadCreated` but have no recorded * `ResponsePosted` state. */ readonly loadThreadsAwaitingResponse: Effect.Effect< - ReadonlyArray>, + ReadonlyArray, AdapterError >; } -export const makeNTBSAdapterTag =

(key: string) => - Context.Service>(key); +export const makeNTBSAdapterTag = (key: string) => Context.Service(key); diff --git a/apps/server/src/ntbs/lifecycle.ts b/apps/server/src/ntbs/lifecycle.ts index cb856d0c6724..ff64166f29db 100644 --- a/apps/server/src/ntbs/lifecycle.ts +++ b/apps/server/src/ntbs/lifecycle.ts @@ -1,24 +1,28 @@ import type { ChatAttachment, MessageId, ThreadId } from "@t3tools/contracts"; -/** - * Describes the platform-specific data of a - * Non-Turn-Based-Surface. - * - * When receiving an NTBS event (a comment, a message tagging - * a bot, etc) `source` and `responseDestination` hold the details - * necessary to process the what and why. - */ -export type PlatformData = { - source: Source; - responseDestination: ResponseDestination; -}; - -export type NTBSInput

= { +export type NTBSInput = { /** - * Each NTBSEvent carries the adapter-defined external data. - * T3 never inspects it. Only the adapter deals with it. + * Adapter-encoded URI locating the originating platform message, + * e.g. `discord:////` or + * `jira:///comment/`. + * + * Two contracts: + * + * Identity — the same platform request must carry the same string + * across redeliveries and restarts; distinct requests must carry + * distinct strings. This is the durable dedup key `findByRequest` + * looks up, the key the processor serializes concurrent deliveries + * on, and the natural unique key for the adapter's stored records. + * + * Addressability — it must contain everything needed to reach the + * message through the platform API from a cold start, because + * recovery reposts with only the stored record. A Discord message + * ID alone fails this: replying requires the channel ID too. + * + * Only the adapter that wrote it may parse it; the processor treats + * it as an opaque string. */ - platformData: P; + sourceUri: string; /** * The captured source text sent as the first T3 user message. * Platform independent. @@ -32,7 +36,7 @@ export type NTBSInput

= { attachments: ReadonlyArray; }; -export type ThreadEvent

= NTBSInput

& { +export type ThreadEvent = NTBSInput & { t3Data: { /** The T3 thread created by the lifecycle event */ threadId: ThreadId; @@ -45,7 +49,7 @@ export type ThreadEvent

= NTBSInput

& { }; }; -export type ThreadCreated

= ThreadEvent

& { +export type ThreadCreated = ThreadEvent & { /** * T3 has created the new thread and the adapter has recorded its relationship * to the platform request. The first turn may not have started yet. @@ -53,9 +57,9 @@ export type ThreadCreated

= ThreadEvent

& { state: "thread.created"; }; -export type ResponsePosted

= ThreadEvent

& { +export type ResponsePosted = ThreadEvent & { state: "thread.response.posted"; responseMessageId: string; }; -export type NTBSLifecycle

= ThreadCreated

| ResponsePosted

; +export type NTBSLifecycle = ThreadCreated | ResponsePosted; diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index aef3361dbba9..7809a8fc7144 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -1,45 +1,31 @@ -import { assert, describe, it } from "@effect/vitest"; +import { describe, it } from "@effect/vitest"; import { NodeServices } from "@effect/platform-node"; import { - CommandId, - EventId, OrchestrationProjectShell, ProviderInstanceId, ThreadId, type OrchestrationCommand, type OrchestrationEvent, } from "@t3tools/contracts"; -import { DateTime, Deferred, Effect, Layer, PubSub, Stream } from "effect"; +import { Deferred, Effect, Layer, PubSub, Stream } from "effect"; import { makeNTBSAdapterTag, ThreadNotFound, type NTBSAdapter } from "./adapter.ts"; -import type { PlatformData } from "./lifecycle.ts"; import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; -import { createAdapterRequest, createGitLayerMock, type TestData } from "./test-helpers.ts"; +import { createAdapterRequest, createGitLayerMock } from "./test-helpers.ts"; import { some } from "effect/Option"; -const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); - -const createRequest = (responseId: string, sourceId: string) => - createAdapterRequest({ - responseDestination: { - responseMessageId: responseId, - }, - source: { - messageId: sourceId, - }, - }); +const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); const makeTestAdapter = Effect.gen(function* () { const eventReceived = yield* Deferred.make(); - const service: NTBSAdapter = { + const service: NTBSAdapter = { save: () => Effect.void, postAcknowledgement: () => Effect.succeed("acknowledgement id"), postResponse: () => Effect.succeed("response id"), - getRequestKey: () => "requestKey", findByRequest: () => Effect.succeed(null), findMatchingResponseMessage: () => Effect.succeed(null), findByThreadId: (threadId) => @@ -56,7 +42,7 @@ const makeTestAdapter = Effect.gen(function* () { }; }); -const TestProcessor = makeNTBSProcessorTag("ntbs/TestProcessor"); +const TestProcessor = makeNTBSProcessorTag("ntbs/TestProcessor"); const makeTestOrchestrationEngine = Effect.gen(function* () { const domainEvents = yield* PubSub.unbounded(); @@ -103,7 +89,7 @@ processes a new request into a T3 thread, starts its first turn, persists lifecy */ const makeTestProcessorLive = ( orchestrationEngine: Layer.Layer, - adapter: Layer.Layer>, + adapter: Layer.Layer, ) => { const gitLayer = createGitLayerMock(); return { @@ -168,7 +154,7 @@ describe("Basic happy case", () => { Effect.gen(function* () { const { processor } = yield* createProcessor; - const request = createRequest("responseId", "sourceIds"); + const request = createAdapterRequest("someRequestId"); yield* processor.process(request.request, request.t3Context); }), diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index dbd565e42f11..ff3b19f14b77 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -72,7 +72,7 @@ export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ cause: unknown; }> {} -export interface NTBSProcessor

{ +export interface NTBSProcessor { /** * Processes a request received by a platform adapter. * @@ -83,7 +83,7 @@ export interface NTBSProcessor

{ * or backpressure for the time being. This choice can be reviewed later. */ readonly process: ( - request: NTBS.NTBSInput

, + request: NTBS.NTBSInput, t3Context: T3Context, ) => Effect.Effect; @@ -100,8 +100,7 @@ export interface NTBSProcessor

{ readonly subscribeToT3Events: Effect.Effect; } -export const makeNTBSProcessorTag =

(key: string) => - Context.Service>(key); +export const makeNTBSProcessorTag = (key: string) => Context.Service(key); type NTBSProcessorRequirements = /* @@ -202,9 +201,9 @@ type TurnStatus = { * * Resolves the required T3 services and returns processor operations with no remaining requirements. */ -export const makeNTBSProcessor =

( - adapterTag: Context.Service>, -): Effect.Effect, never, AdapterId | NTBSProcessorRequirements> => +export const makeNTBSProcessor = ( + adapterTag: Context.Service, +): Effect.Effect => Effect.gen(function* () { const adapter = yield* adapterTag; @@ -424,7 +423,7 @@ export const makeNTBSProcessor =

( * contains the response, it is recorded instead of reposted. */ const postResponse = ( - threadCreated: NTBS.ThreadCreated

, + threadCreated: NTBS.ThreadCreated, response: NTBSResponse, ): Effect.Effect => Effect.gen(function* () { @@ -911,7 +910,7 @@ export const makeNTBSProcessor =

( * is active, or posts its outcome when it already finished. */ const recoverThread = ( - threadCreated: NTBS.ThreadCreated

, + threadCreated: NTBS.ThreadCreated, ): Effect.Effect => Effect.gen(function* () { const { threadId, userMessageId } = threadCreated.t3Data; @@ -994,7 +993,7 @@ export const makeNTBSProcessor =

( 6. Attempt to post the acknowledgement independently. */ - const processAdapterRequest = (request: NTBS.NTBSInput

, t3Context: T3Context) => + const processAdapterRequest = (request: NTBS.NTBSInput, t3Context: T3Context) => Effect.gen(function* () { /* In-flight dedup first. We check if the processor is *currently* @@ -1002,7 +1001,7 @@ export const makeNTBSProcessor =

( Later we check for the *durable* dedup: are we receiving a request for work that has *already* completed. */ - const key = adapter.getRequestKey(request); + const key = request.sourceUri; const isBeingWorkedNow = inFlightRequests.has(key); if (isBeingWorkedNow) { @@ -1028,7 +1027,7 @@ export const makeNTBSProcessor =

( // generate the first user message ID and record it with ThreadCreated const userMessageId = MessageId.make(yield* randomUUID); - const threadCreated: NTBS.ThreadCreated

= { + const threadCreated: NTBS.ThreadCreated = { ...request, state: "thread.created", t3Data: { @@ -1074,7 +1073,7 @@ export const makeNTBSProcessor =

( }).pipe(Effect.ensuring(Effect.sync(() => inFlightRequests.delete(key)))); }); - const process = (request: NTBS.NTBSInput

, t3Context: T3Context) => + const process = (request: NTBS.NTBSInput, t3Context: T3Context) => processAdapterRequest(request, t3Context); const consumeT3Events = Stream.runForEach( diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts index 1f37c43745a7..f7c4934e1316 100644 --- a/apps/server/src/ntbs/processor2.test.ts +++ b/apps/server/src/ntbs/processor2.test.ts @@ -16,7 +16,7 @@ import { type NTBSAdapter, type NTBSResponse, } from "./adapter.ts"; -import type { NTBSInput, NTBSLifecycle, PlatformData, ThreadCreated } from "./lifecycle.ts"; +import type { NTBSInput, NTBSLifecycle, ThreadCreated } from "./lifecycle.ts"; import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -24,8 +24,6 @@ import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurn import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; -type TestData = PlatformData<{ messageId: string }, { responseMessageId: string }>; - /* Harness layout: @@ -87,10 +85,10 @@ class TestAdapterState extends Context.Service< TestAdapterState, { /** Lifecycle records keyed by T3 thread — seed before acting, inspect after. */ - readonly records: Map>; - readonly postedAcks: Array>; + readonly records: Map; + readonly postedAcks: Array; readonly postedResponses: Array<{ - readonly record: ThreadCreated; + readonly record: ThreadCreated; readonly response: NTBSResponse; }>; /** One entry per findByThreadId call; taking from it awaits event delivery. */ @@ -101,7 +99,7 @@ class TestAdapterState extends Context.Service< TestAdapterState, Effect.gen(function* () { return { - records: new Map>(), + records: new Map(), postedAcks: [], postedResponses: [], threadLookups: yield* Queue.unbounded(), @@ -110,14 +108,14 @@ class TestAdapterState extends Context.Service< ); } -const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); +const TestAdapter = makeNTBSAdapterTag("ntbs/TestAdapter"); const AdapterFromState = Layer.effect( TestAdapter, Effect.gen(function* () { const state = yield* TestAdapterState; - const adapter: NTBSAdapter = { + const adapter: NTBSAdapter = { save: (lifecycleEvent) => Effect.sync(() => { state.records.set(lifecycleEvent.t3Data.threadId, lifecycleEvent); @@ -132,14 +130,11 @@ const AdapterFromState = Layer.effect( state.postedResponses.push({ record, response }); return `response-${state.postedResponses.length}`; }), - getRequestKey: (request) => request.platformData.source.messageId, findByRequest: (request) => Effect.sync( () => - [...state.records.values()].find( - (record) => - record.platformData.source.messageId === request.platformData.source.messageId, - ) ?? null, + [...state.records.values()].find((record) => record.sourceUri === request.sourceUri) ?? + null, ), findMatchingResponseMessage: () => Effect.succeed(null), findByThreadId: (threadId) => @@ -153,7 +148,7 @@ const AdapterFromState = Layer.effect( ), loadThreadsAwaitingResponse: Effect.sync(() => [...state.records.values()].filter( - (record): record is ThreadCreated => record.state === "thread.created", + (record): record is ThreadCreated => record.state === "thread.created", ), ), }; @@ -162,7 +157,7 @@ const AdapterFromState = Layer.effect( }), ); -const TestProcessor = makeNTBSProcessorTag("ntbs/TestProcessor"); +const TestProcessor = makeNTBSProcessorTag("ntbs/TestProcessor"); const Harness = Layer.effect(TestProcessor, makeNTBSProcessor(TestAdapter)).pipe( Layer.provide(AdapterFromState), @@ -206,19 +201,13 @@ const sessionSetEvent = (threadId: ThreadId): Effect.Effect }; }); -const makeRequest = (platformMessageId: string): NTBSInput => ({ - platformData: { - source: { messageId: platformMessageId }, - responseDestination: { responseMessageId: "destination" }, - }, +const makeRequest = (platformMessageId: string): NTBSInput => ({ + sourceUri: platformMessageId, snapshot: "please look into this", attachments: [], }); -const recordedThread = ( - request: NTBSInput, - threadId: ThreadId, -): ThreadCreated => ({ +const recordedThread = (request: NTBSInput, threadId: ThreadId): ThreadCreated => ({ ...request, state: "thread.created", t3Data: { threadId, userMessageId: MessageId.make(`message-for-${threadId}`) }, diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts index 8f8cacb651a2..b2f632f6bc3e 100644 --- a/apps/server/src/ntbs/test-helpers.ts +++ b/apps/server/src/ntbs/test-helpers.ts @@ -7,7 +7,7 @@ import { ThreadId, VcsCreateWorktreeResult, } from "@t3tools/contracts"; -import type { NTBSInput, NTBSLifecycle, PlatformData, ThreadCreated } from "./lifecycle.ts"; +import type { NTBSInput, NTBSLifecycle, ThreadCreated } from "./lifecycle.ts"; import type { T3Context } from "./processor.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { makeNTBSAdapterTag, ThreadNotFound, type NTBSResponse } from "./adapter.ts"; @@ -55,20 +55,16 @@ export const createGitLayerMock = () => { }; }; -export const createAdapterRequest = (input: { - responseDestination: Destination; - source: Source; -}): { - request: NTBSInput>; +export const createAdapterRequest = ( + uniqueId: string, +): { + request: NTBSInput; t3Context: T3Context; } => ({ request: { snapshot: "This is an ongoing discussion", attachments: [], - platformData: { - responseDestination: input.responseDestination, - source: input.source, - }, + sourceUri: uniqueId, }, t3Context: { baseRef: "fork/dev", @@ -76,8 +72,6 @@ export const createAdapterRequest = (input: { }, }); -export type TestData = PlatformData<{ messageId: string }, { responseMessageId: string }>; - /* # Testing strategy @@ -174,13 +168,12 @@ class TestAdapterState extends Context.Service< /** * Lifecycle records keyed by T3 thread. */ - readonly records: Map>; - // readonly lifecycleEvents: Array>; - readonly postedAcks: Map>; + readonly records: Map; + readonly postedAcks: Map; readonly postedResponses: Map< string, { - readonly record: ThreadCreated; + readonly record: ThreadCreated; readonly response: NTBSResponse; } >; @@ -196,12 +189,12 @@ class TestAdapterState extends Context.Service< Effect.gen(function* () { return { // lifecycleEvents: [], - records: new Map>(), - postedAcks: new Map>(), + records: new Map(), + postedAcks: new Map(), postedResponses: new Map< string, { - readonly record: ThreadCreated; + readonly record: ThreadCreated; readonly response: NTBSResponse; } >(), @@ -211,7 +204,7 @@ class TestAdapterState extends Context.Service< ); } -const TestAdapter = makeNTBSAdapterTag("test/ntbs/TestAdapter"); +const TestAdapter = makeNTBSAdapterTag("test/ntbs/TestAdapter"); const TestAdapterFromState = Layer.effect( TestAdapter, @@ -246,9 +239,7 @@ const TestAdapterFromState = Layer.effect( .entries() .map((el) => el[1]) .find((entry) => { - return ( - entry.platformData.source.messageId === request.platformData.source.messageId - ); + return entry.sourceUri === request.sourceUri; }) ?? null ); }), @@ -260,15 +251,13 @@ const TestAdapterFromState = Layer.effect( }), findMatchingResponseMessage: (state) => Effect.sync(() => { - const messageId = state.platformData.source.messageId; const maybeResponse = adapterState.postedResponses .entries() - .find(([_id, posted]) => posted.record.platformData.source.messageId === messageId); + .find(([_id, posted]) => posted.record.sourceUri === state.sourceUri); return maybeResponse ? maybeResponse[0] : null; }), - getRequestKey: (request) => request.platformData.source.messageId, loadThreadsAwaitingResponse: Effect.sync(() => { - const awaitingResponse: ThreadCreated[] = []; + const awaitingResponse: ThreadCreated[] = []; adapterState.records.forEach((state) => { if (state.state === "thread.created") { awaitingResponse.push(state); From 4b71c96f9eb8fda3a6065a8618b72a839b12e161 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 10:52:05 +0200 Subject: [PATCH 060/110] fix: typescript stuff --- apps/server/src/ntbs/processor.test.ts | 11 ++++++----- apps/server/src/ntbs/processor.ts | 2 +- apps/server/src/ntbs/processor2.test.ts | 4 ++-- apps/server/src/ntbs/test-helpers.ts | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index 7809a8fc7144..5e308186c604 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -7,7 +7,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, } from "@t3tools/contracts"; -import { Deferred, Effect, Layer, PubSub, Stream } from "effect"; +import { DateTime, Deferred, Effect, Layer, PubSub, Stream } from "effect"; import { makeNTBSAdapterTag, ThreadNotFound, type NTBSAdapter } from "./adapter.ts"; import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; @@ -99,10 +99,11 @@ const makeTestProcessorLive = ( Layer.provide( Layer.mock(ProjectionSnapshotQuery)({ getProjectShellById: (projectId) => - Effect.sync(() => { + Effect.gen(function* () { + const now = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); return some( OrchestrationProjectShell.make({ - createdAt: new Date().toISOString(), + createdAt: now, id: projectId, title: "project title", workspaceRoot: "workspaceRoot", @@ -110,7 +111,7 @@ const makeTestProcessorLive = ( model: "gpt-does-not-exist-v2", instanceId: ProviderInstanceId.make("gpt-does-not-exist-v2"), }, - updatedAt: new Date().toISOString(), + updatedAt: now, scripts: [], }), ); @@ -121,7 +122,7 @@ const makeTestProcessorLive = ( Layer.provide(gitLayer.layer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ - runForThread: (input) => + runForThread: (_) => Effect.sync(() => { return { status: "no-script" }; }), diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index ff3b19f14b77..7063bad68bb6 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1118,7 +1118,7 @@ export const makeNTBSProcessor = ( Effect.gen(function* () { yield* consumeT3Events.pipe(Effect.forkScoped({ startImmediately: true })); yield* recoverStoredThreads; - yield* Effect.never; + return yield* Effect.never; }), ); diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts index f7c4934e1316..00f1f48e3544 100644 --- a/apps/server/src/ntbs/processor2.test.ts +++ b/apps/server/src/ntbs/processor2.test.ts @@ -47,7 +47,7 @@ class TestEngine extends Context.Service< readonly publish: (event: OrchestrationEvent) => Effect.Effect; readonly domainEvents: PubSub.PubSub; } ->()("test/ntbs/TestEngine") { +>()("t3/ntbs/processor2.test/TestEngine") { static readonly layer = Layer.effect( TestEngine, Effect.gen(function* () { @@ -94,7 +94,7 @@ class TestAdapterState extends Context.Service< /** One entry per findByThreadId call; taking from it awaits event delivery. */ readonly threadLookups: Queue.Queue; } ->()("test/ntbs/TestAdapterState") { +>()("t3/ntbs/processor2.test/TestAdapterState") { static readonly layer = Layer.effect( TestAdapterState, Effect.gen(function* () { diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts index b2f632f6bc3e..05c64c6d0674 100644 --- a/apps/server/src/ntbs/test-helpers.ts +++ b/apps/server/src/ntbs/test-helpers.ts @@ -110,7 +110,7 @@ class TestEngine extends Context.Service< readonly domainEvents: PubSub.PubSub; } ->()("test/ntbs/TestEngine") { +>()("t3/ntbs/test-helpers/TestEngine") { static readonly layer = Layer.effect( TestEngine, Effect.gen(function* () { @@ -183,7 +183,7 @@ class TestAdapterState extends Context.Service< */ readonly threadLookups: Queue.Queue; } ->()("test/ntbs/TestAdapterState") { +>()("t3/ntbs/test-helpers/TestAdapterState") { static readonly layer = Layer.effect( TestAdapterState, Effect.gen(function* () { From d15a33ee36000a6392ac445bfb8bcd335f9b328e Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 13:45:00 +0200 Subject: [PATCH 061/110] docs: review NTBS processor refinements --- docs/planning/ntbs-processor.cod-review.md | 155 +++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/planning/ntbs-processor.cod-review.md diff --git a/docs/planning/ntbs-processor.cod-review.md b/docs/planning/ntbs-processor.cod-review.md new file mode 100644 index 000000000000..1d0d7e524c22 --- /dev/null +++ b/docs/planning/ntbs-processor.cod-review.md @@ -0,0 +1,155 @@ +# NTBS processor review + +Scope: the six files currently under `apps/server/src/ntbs`, their direct code references, and the orchestration/persistence behavior on which the processor relies. I did not use the other planning documents as input. + +The implementation has a sound core idea: one opaque external-request locator, one fresh T3 thread, an exact user-message ID for finding the corresponding turn, and a small two-state adapter record. The main remaining refinements are to make ownership singular and durability explicit. At present there are no production imports, adapter implementations, or runtime wiring outside `apps/server/src/ntbs`; only the NTBS tests reference these exports. That is fine for a branch still defining the component, but the current code is inert until an adapter and processor lifecycle are wired. + +## 1. Simplifications, naming, and contracts + +### S1. Use one completion driver instead of polling and events + +The same turn is currently owned by two mechanisms: + +- `monitorT3Turn` polls the turn projection, detects terminal state, and then only returns ([processor.ts](../../apps/server/src/ntbs/processor.ts#L738)). +- `processT3Event` listens for `thread.session-set`, queries the same turn projection, and posts the result ([processor.ts](../../apps/server/src/ntbs/processor.ts#L455)). +- `responseLocks`, `messageStatus`, and the recovery choreography exist largely to keep those two paths from producing competing outcomes ([processor.ts](../../apps/server/src/ntbs/processor.ts#L223)). + +The smallest design is to let the monitor reconcile every observation: when it sees a terminal turn, resolve and post that outcome; when it reaches the timeout policy, interrupt and post the timeout. Startup recovery only needs to start a monitor for each pending record. This would remove `processT3Event`, `consumeT3Events`, the hot-stream dependency, and most or all of `responseLocks`. Final replies would be delayed by at most the polling interval, currently 15 seconds. + +If near-immediate replies are a hard requirement, choose the opposite ownership model: make the event consumer the sole terminal-outcome driver and keep a timer only for timeouts. The important simplification is not which one wins; it is that terminal response delivery has one owner. + +### S2. Replace generic storage operations with explicit state transitions + +`NTBSAdapter.save` can write either lifecycle variant with no transition or uniqueness semantics. The processor separately calls `findByRequest`, creates resources, and later calls `save`. This is a broad API for a narrow state machine and leaves the important guarantees implicit. + +A plainer repository contract would expose intent rather than arbitrary persistence: + +- `claimRequest(request)` atomically inserts the external request and reports whether this caller claimed it; +- `attachThread(requestUri, threadId, userMessageId)` records the created T3 resources; +- `findByThreadId(threadId)` returns an option/null rather than a second absence convention (`ThreadNotFound`); +- `listPendingResponses()` replaces the effect-valued `loadThreadsAwaitingResponse` name; +- `markResponded(threadId, responseMessageId)` is the only terminal transition. + +This adds a small durable `claimed`/`provisioning` state, but removes `inFlightRequests` as a correctness mechanism, prevents backwards writes such as `ResponsePosted -> ThreadCreated`, and makes adapter conformance testable. The existing Jira delivery store already uses this shape: it claims a delivery before thread/worktree side effects. + +The outbound half should likewise be one adapter operation, for example `postResponseOnce(record, response)`, with a documented stable platform marker or idempotency key. The current `findMatchingResponseMessage` followed by `postResponse` makes the processor understand an adapter-specific recovery protocol, yet still cannot make the pair atomic. + +### S3. Remove surface and data that carry no behavior + +These cuts are mechanical and do not change the design: + +- `process` is only an alias for `processAdapterRequest` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1076)); keep one name. +- `resolveT3Outcome` returns `{ threadId, response }`, but every caller already has the thread ID and uses only `response` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L345)). Return `NTBSResponse | null`. +- `TurnStatus.recordedAt` is written on every observation and never read ([processor.ts](../../apps/server/src/ntbs/processor.ts#L190)). Remove it unless the timeout is changed to elapsed-time semantics. +- The exact-turn lookup and its “exactly one” error are duplicated in `resolveT3Outcome` and `loadMessageStatus` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L353), [processor.ts](../../apps/server/src/ntbs/processor.ts#L646)). Extract one `loadRequestTurn(threadId, userMessageId)` helper. +- `postAcknowledgement` returns a platform message ID that is discarded ([adapter.ts](../../apps/server/src/ntbs/adapter.ts#L35), [processor.ts](../../apps/server/src/ntbs/processor.ts#L1061)). Return `void` unless acknowledgement recovery is going to persist that ID. +- `makeNTBSProcessorTag` has no consumer except the test harness. The factory already returns the service value, so the extra tag factory can wait until production wiring demonstrates a need. `makeNTBSAdapterTag` remains useful if several adapter-specific processor layers are actually constructed. + +`getTurnStats` should stay conservative for now. Some of its fields look correlated, but removing activity count, last activity ID, assistant length, or update time without first checking provider projection behavior would weaken stall detection for little benefit. + +### S4. Use record-oriented, plain names + +The current types mix events, T3 lifecycle terms, and stored adapter state. They are records rather than domain events, and the processor assumes exactly one external request per fresh T3 thread even though “latest lifecycle state associated with a T3 thread” suggests otherwise. + +| Current | Plainer option | Reason | +| --------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `NTBSLifecycle` | `NTBSRequestRecord` | It is the adapter's current stored record, not a lifecycle process. | +| `ThreadEvent` | `ThreadRequestRecord` or no standalone base alias | Nothing emits this value as an event. | +| `ThreadCreated` | `PendingResponse` | The processor primarily cares that this record still needs a response. | +| `ResponsePosted` | `RespondedRequest` | Names the terminal request state. | +| `t3Data` | `thread` | `record.thread.threadId` and `record.thread.userMessageId` state the contents directly. | +| `T3Context` | `ThreadTarget` | It is only the project and base ref used to create a thread. | +| `snapshot` | `prompt` or `capturedText` | The value is sent verbatim as the first user message; “snapshot” does not say of what. | +| `postAcknowledgement` | `acknowledge` | The operation is best-effort and its result is unused. | +| `subscribeToT3Events` | `run` (if it remains long-lived) or `recoverPending` (if polling owns completion) | The current operation also performs recovery and never returns. | + +The recent removal of generic `PlatformData` is a good simplification and should not be reversed. Keeping one opaque, adapter-owned URI is easier to persist and recover. `sourceUri` could become `requestUri` to emphasize both identity and addressability, but that rename is optional; the more important change is to make it a validated, non-empty value. + +### S5. Make the input contract executable + +`NTBSInput` is a plain TypeScript type whose strongest requirements exist only in comments. In particular, `sourceUri` may be empty, `snapshot` may be blank or exceed 120,000 characters, and the attachment array may exceed the provider limit of eight. The orchestration turn-start command accepts an unrestricted string/array; the tighter provider validation happens later, after a worktree, thread, and lifecycle record already exist. + +Define an Effect schema for the inbound boundary and reuse `PROVIDER_SEND_TURN_MAX_INPUT_CHARS`, `PROVIDER_SEND_TURN_MAX_ATTACHMENTS`, and `ChatAttachment`. Decode it before claiming or creating resources. This is both less prose to keep synchronized and a clearer contract for every future adapter. + +### S6. Consolidate the transitional test suite + +The directory currently carries two harnesses and two processor test files: + +- `processor.test.ts` contains one “happy case” that only calls `process`; it has no assertions despite the preceding checklist ([processor.test.ts](../../apps/server/src/ntbs/processor.test.ts#L70), [processor.test.ts](../../apps/server/src/ntbs/processor.test.ts#L153)). +- `processor2.test.ts` is the more coherent layer harness and should become the sole `processor.test.ts`. +- Most of `test-helpers.ts` after `createAdapterRequest` is an unfinished second copy of the same harness and is not exported or used ([test-helpers.ts](../../apps/server/src/ntbs/test-helpers.ts#L75)). +- `createGitLayerMock` returns `input.refName` as the created worktree branch rather than `input.newRefName`, so a future command assertion would observe the base commit as the thread branch ([test-helpers.ts](../../apps/server/src/ntbs/test-helpers.ts#L34)). + +Delete the no-assertion test and unused helper harness, rename `processor2.test.ts`, and grow that one harness around state transitions. The two focused test files currently pass (four tests total), but that result says little about the end-to-end lifecycle because only three tests make assertions and none completes a real request-to-response path. + +## 2. Bugs, edge cases, and race conditions + +### B1. Blocking before integration: no production code constructs or runs NTBS + +No code outside `apps/server/src/ntbs` imports `makeNTBSProcessor`, `makeNTBSAdapterTag`, `NTBSProcessor`, or `subscribeToT3Events`. There is also no production adapter implementation. Consequently neither request processing nor startup recovery can currently execute. Treat this as integration status rather than an algorithm bug, but it is the first readiness item before assessing runtime behavior. + +### B2. High: inbound deduplication is a check-then-act race + +`inFlightRequests` protects only one in-memory processor instance. After that local check, `findByRequest` and resource creation are separate effects ([processor.ts](../../apps/server/src/ntbs/processor.ts#L996)). Two server processes, two processor instances, or an overlapping restart can both observe no record and both create a worktree/thread for the same `sourceUri`. The adapter contract recommends a natural unique key but does not require an atomic insert or define conflict behavior. + +Use the atomic `claimRequest` transition described in S2 and enforce a unique key in adapter storage. The in-memory set may remain as a cheap duplicate suppressor, but it must not be the correctness boundary. + +### B3. High: the durable record is written after irreversible resources are created + +`createT3Thread` creates a worktree, dispatches `thread.create`, and runs setup before `ThreadCreated` is saved ([processor.ts](../../apps/server/src/ntbs/processor.ts#L790), [processor.ts](../../apps/server/src/ntbs/processor.ts#L1024)). A process exit after `thread.create` commits, a process exit during setup, or an adapter `save` failure leaves a real T3 thread/worktree with no request record. The next delivery sees no record and creates another one. Cleanup only covers failure of `thread.create` itself; it cannot cover a successful dispatch followed by process loss. + +Claim and persist the request before provisioning. Record the generated thread/message IDs as soon as they are chosen, then make provisioning/recovery resume from that record. Deterministic IDs derived from the claim would be another option, but are not necessary if the state transition is durable. + +### B4. High: a saved request can become dormant after turn-start failure + +The processor saves `ThreadCreated` and then dispatches `thread.turn.start` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1039)). If turn start fails or the processing fiber is interrupted after the save, the record correctly remains pending. However, a redelivery finds any existing lifecycle state and immediately returns ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1017)). `recoverThread` can start a missing turn, but it runs only during `subscribeToT3Events` startup, not on redelivery. + +Make `process` mean “ensure this request is processing”: when `findByRequest` returns a pending record, call the same idempotent reconciliation used by startup recovery. Only a responded record should be an immediate no-op. This also collapses the split between normal processing and recovery. + +### B5. High: response delivery has both a retry gap and a cross-process duplicate race + +When `postResponse` or `findMatchingResponseMessage` fails, the event consumer logs the failure and continues ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1079)). The monitor independently sees that the turn is terminal and exits ([processor.ts](../../apps/server/src/ntbs/processor.ts#L756)). With no later `thread.session-set` event, nothing retries until the whole processor restarts. + +Conversely, two processor instances can both run `findMatchingResponseMessage`, both receive `null`, and both post before either saves `ResponsePosted`. The user-message semaphore is process-local and does not prevent this. The existing recovery check helps only after one response is visible; it is not an atomic exactly-once guarantee. Its contract also does not say how an adapter distinguishes a final response from an acknowledgement when both relate to the same source URI. + +Use a durable response-delivery claim/outbox plus a stable platform marker, or make `postResponseOnce` an explicitly idempotent adapter primitive. Retry pending delivery on a bounded schedule in the running process; startup recovery should be the fallback, not the normal retry mechanism. + +### B6. High: timeout can claim work stopped when it is still running + +The comments correctly state that unchanged projected stats do not prove a stall ([processor.ts](../../apps/server/src/ntbs/processor.ts#L141)), but the implementation treats 12 unchanged 15-second polls—about three minutes—as a stall and interrupts the turn ([processor.ts](../../apps/server/src/ntbs/processor.ts#L734)). A coding turn can legitimately spend that long in provider work, a subprocess, or buffered output with no new projected activity. + +More importantly, interrupt failure is caught and ignored, after which a timeout response is posted anyway ([processor.ts](../../apps/server/src/ntbs/processor.ts#L534)). Even a successful dispatch only proves that the interrupt command was accepted, not that the provider stopped. The full-access agent may therefore continue modifying the worktree after the external platform is told that T3 “stopped this request.” + +Use a configurable elapsed-time SLA and treat observed progress only as a deadline extension, not proof that a short silence is a stall. After interrupt, wait for provider/session confirmation that the turn is no longer running before claiming it stopped. The turn projection alone is insufficient because `thread.turn-interrupt-requested` marks it interrupted when the request is recorded, before provider shutdown is confirmed. If confirmation cannot be obtained, use honest text such as “The response timed out; the T3 thread may still be running” and link or identify the thread rather than asserting cancellation. + +### B7. Medium: detached monitors have no processor-owned lifetime + +Both normal processing and recovery start monitors with `Effect.forkDetach` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L971), [processor.ts](../../apps/server/src/ntbs/processor.ts#L1050)). Interrupting the scoped `subscribeToT3Events` effect stops the event subscription but not those monitors. Rebuilding the layer can leave old monitors using the same adapter while new recovery monitors start, and tests/runtime shutdown cannot reliably await their completion. + +Fork monitors in a processor-owned scope keyed by user message ID, and interrupt that scope when the processor stops. This also provides a direct place to prevent duplicate monitors without a second free-floating map. + +### B8. Medium: event processing is serial and includes remote adapter I/O + +`Stream.runForEach` processes domain events one at a time, and `processT3Event` may perform adapter lookup, response search, response posting, and persistence before the next event is consumed ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1079)). One slow or hung platform call therefore blocks outcomes for every other NTBS thread and lets the unbounded event PubSub backlog grow. + +Removing the duplicate event path as in S1 eliminates this issue. If the event path stays, route relevant events to per-request fibers with bounded concurrency; keep the per-request serialization at the durable response transition. + +### B9. Medium: unique requests have no resource bound + +The API explicitly accepts unlimited concurrent distinct requests ([processor.ts](../../apps/server/src/ntbs/processor.ts#L75)). Each can fetch `origin`, create a worktree, run setup, start a full-access provider turn, and retain a monitor. A webhook replay or burst of legitimate messages can exhaust disk, git subprocesses, or provider capacity even though duplicate URIs are suppressed. + +Put a configurable bound around accepted active requests, ideally at the durable claim/queue boundary so restarts do not discard queued work. At minimum, bound provisioning per project; concurrent `git fetch` and worktree setup for the same repository provide little benefit. + +### B10. Medium: invalid input fails after side effects instead of at admission + +Because the comment-only input invariants are not decoded, an empty URI can collapse unrelated requests onto one dedup key, and over-limit text/attachments can be accepted through orchestration only to fail at the provider boundary after resources and a pending record exist. Validate before the durable claim as described in S5, and return a stable rejected outcome rather than relying on a later provider error. + +### B11. Low: an exact-turn error can use another turn's error text + +The processor carefully selects the turn by the recorded user-message ID, but for an errored turn it reads `thread.session.lastError`, which is thread-wide current session state ([processor.ts](../../apps/server/src/ntbs/processor.ts#L399)). If the thread later receives another turn, that message may describe the later session rather than the NTBS turn. Until errors are stored per turn, prefer the generic failure text over potentially incorrect detail, or only use `lastError` when the selected turn is also the current/latest turn. + +### B12. Confidence gap: critical transitions are untested + +The current tests do not cover a successful create/start/terminal-response lifecycle, missing-turn recovery, a response found remotely after local-save failure, concurrent completion versus timeout, duplicate concurrent deliveries, retry after turn-start failure, timeout interruption, or monitor cleanup. These are exactly the paths where the implementation carries custom locks and recovery logic. After consolidating the harness, cover those transitions with controllable deferred adapter calls and a test clock; that will also make it safe to remove the redundant ownership machinery. + +I specifically did not flag a projection/publication race: the orchestration engine applies the projection in the same transaction before publishing each event to `streamDomainEvents`. I also did not treat best-effort setup-script failure or the documented temporary-branch leak as new NTBS bugs; both match existing bridge behavior and are explicit choices in the current code. From 8786971f927aa4d9032e199b32b49bfd5e75aa3e Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 16:45:25 +0200 Subject: [PATCH 062/110] chore: claude code review --- docs/planning/ntbs-processor.cc-review.md | 118 ++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/planning/ntbs-processor.cc-review.md diff --git a/docs/planning/ntbs-processor.cc-review.md b/docs/planning/ntbs-processor.cc-review.md new file mode 100644 index 000000000000..2b5bcd76b757 --- /dev/null +++ b/docs/planning/ntbs-processor.cc-review.md @@ -0,0 +1,118 @@ +# NTBS directory review + +**Status:** review notes (Claude Code, 2026-08-14) +**Scope:** `apps/server/src/ntbs/` — `processor.ts`, `adapter.ts`, `lifecycle.ts`, `test-helpers.ts`, `processor.test.ts`, `processor2.test.ts` — checked against the projection pipeline, decider, provider runtime ingestion, and the planning docs. + +Overall the shape is right: the processor/adapter boundary is clean, the outcome lock design is sound, and the recovery model (durable record + turn lookup + `findMatchingResponseMessage`) handles the crash windows it was designed for. The findings below are refinements, ordered by how much I'd want them fixed. + +--- + +## 1. Simplifications + +### API and business logic + +**S1. `messageStatus` is fiber-local state wearing a Map costume** — `processor.ts:636` +The map has exactly one reader, `checkProgress` (`processor.ts:698`); every other use is a write or delete from the same monitor's own control flow. The progress baseline can live in a local variable inside `monitorT3Turn`, with the initial `TurnStatus` passed in as a parameter. That deletes: + +- the `"No monitoring state exists"` error case (`processor.ts:700-704`), which cannot occur except through the map indirection itself; +- the delete/set baseline dance in `checkProgress` (`processor.ts:725-729`) and the second delete in the monitor's `ensuring` (`processor.ts:777`); +- the `TurnStats` / `TurnStatus` name near-collision, since `TurnStatus` mostly dissolves. + +If you keep any shared structure, its only genuine job is "is someone already monitoring this message" — a `Set` — which is exactly what bug **B2** needs. So: baseline goes fiber-local, the map becomes a Set used for recovery dedup. + +**S2. Three copies of the turn-for-message lookup** — `processor.ts:353-365`, `646-658`, `917-929` +`resolveT3Outcome`, `loadMessageStatus`, and `recoverThread` each do `listByThreadId` → filter on `pendingMessageId === userMessageId` → cardinality check with a hand-built error. Extract one `findTurnForMessage(threadId, userMessageId)` helper. This is also the single place to implement the found-0 handling from bug **B1** — three call sites collapse into one decision point. + +**S3. `TurnStatus.recordedAt` is write-only** — `processor.ts:194` +Set in four places (`661`, `683`, `940`, `1047`), read nowhere. Drop the field and the `getNow` calls that feed it. With S1, `TurnStatus` shrinks to `{ threadId, stats }`. + +**S4. `resolveT3Outcome` echoes a `threadId` both callers already hold** — `processor.ts:348-350` +Both call sites (`processor.ts:502-507`, `959-961`) use only `outcome.response`. Return `NTBSResponse | null`. + +**S5. `postAcknowledgement`'s return value is dead contract** — `adapter.ts:43` +The processor discards it (`processor.ts:1061-1070`, `Effect.asVoid`), and the simplified lifecycle no longer stores an acknowledgement message ID. Make it `Effect`; an adapter that needs the ID for its own `findMatchingResponseMessage` heuristics can store it internally. Right now the signature promises a use that doesn't exist. + +**S6. Half of `test-helpers.ts` is dead code** — `test-helpers.ts:99-270` +`TestEngine`, `TestAdapterState`, `TestAdapter`, and both derived layers are unexported and unused (only `createGitLayerMock` and `createAdapterRequest` are imported, by `processor.test.ts`). `processor2.test.ts` contains its own — already divergent — copies of the same fakes. Pick one harness (the `processor2.test.ts` one is the better design: state services + `Layer.provideMerge`, and the `threadLookups` queue-as-synchronization trick is good), move it into `test-helpers.ts`, and delete the rest. Fold `processor.test.ts` into the same file while at it: its single test (`processor.test.ts:153-163`) asserts nothing — it passes if `process` doesn't die — and the `eventReceived` Deferred in its adapter is never awaited. The assertions it was meant to make are already enumerated in `docs/planning/processor-testing.md` steps 1–8; write them against the surviving harness. + +**S7. Small cuts** + +- `process` is a rename of `processAdapterRequest` (`processor.ts:1076-1077`) — define `process` directly. +- `else` + trailing `return` after the early-return dedup branch (`processor.ts:1021-1073`) — flatten. +- `runtimeMode: "full-access"` + `interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE` are hardcoded twice (`processor.ts:309-310`, `847-848`) — one module-level constant pair, which is also where a future per-request override would land. + +### Naming and contracts + +**N1. `subscribeToT3Events` undersells itself** — `processor.ts:100`, `1117-1123` +It subscribes, recovers stored threads, forks monitors, and then runs forever. Callers wire it as the processor's main loop. Call it `run` (or `start`), and keep the doc comment as is — the comment is accurate, the name isn't. + +**N2. `ThreadEvent` is not an event** — `lifecycle.ts:39` +It's the stored record shape (input + T3 ids); the states are `ThreadCreated`/`ResponsePosted` and the union is already correctly named `NTBSLifecycle`. `ThreadRecord` (or `LifecycleBase`) says what it is. Same file: the fields are mutable while everything in `processor.ts` is `readonly` — make the contract types `readonly` too. + +**N3. Misleading or stale comments** + +- `processor.ts:477-480`: "we're only interested in the last user message that appears in the adapter records" — it's the _original_ user message recorded for the request, and it's the only one the adapter knows. "Last" implies a selection that doesn't happen. +- `lifecycle.ts:32-36`: "The processor creates them from attachment data provided by the adapter" — the processor passes `attachments` through untouched (`processor.ts:1044`). The adapter creates them. +- `adapter.ts:48`: typo "idenitifier". + +**N4. `adapter.save` doesn't say it's an upsert or name its key** — `adapter.ts:33-36` +The processor calls `save` twice per lifecycle (created, then posted) and expects the second write to replace the first. Both test adapters guessed "keyed by threadId". State it: "Upserts the record for this request; `sourceUri` (equivalently the T3 thread, they're 1:1) is the identity." + +**N5. `snapshot`'s 120k limit names no enforcer** — `lifecycle.ts:27-31` +The processor doesn't validate it. Either say "the adapter must truncate/enforce before calling" or drop the sentence — as written it reads like a checked precondition. + +**N6. `NTBSResponse.text` ownership for non-answer types** — `adapter.ts:14-17` +The processor bakes fixed English copy for `failure`/`timeout`/`cancellation` (`processor.ts:393-395`, `404`, `413`, `548-553`). If the intent is that adapters may re-render per platform, say on the type that `type` is the contract and `text` a default the adapter may replace; otherwise every platform ships the processor's prose. + +**N7. Error-message style drifts** — e.g. "Problems getting the thread from the projection" (`processor.ts:672`) vs. the "Failed …" convention everywhere else. Cheap consistency win when touching those lines. + +**N8. Spell out the acronym once** — none of the three source files says what NTBS stands for; the architecture block at `processor.ts:28` is where "Non-Turn-Based Surfaces" belongs. + +--- + +## 2. Bugs, edge cases, race conditions + +**B1. A turn that never materializes leaves the request permanently unanswered (until restart)** — `processor.ts:360-365`, `653-658` +Mechanism, confirmed against the projection code: `thread.turn.start` emits `thread.message-sent` + `thread.turn-start-requested` (decider `planTurnStartEvents`), which writes the pending-start row carrying `pendingMessageId`. If the provider session settles before adopting that row — provider spawn failure, bad model config, runtime error before `turn.started` — the projection **deletes the pending row** (`ProjectionPipeline.ts:1347-1358`, "any settled status abandons an unadopted pending turn start") and no concrete turn row ever exists. From then on both turn lookups find **0** matching turns and hard-error: + +- the `thread.session-set(error)` that reports the failure reaches `processT3Event`, which calls `resolveT3Outcome`, gets the "Expected exactly one turn, found 0" error, logs a warning, and moves on — the failure outcome is never posted; +- the monitor's `loadMessageStatus` fails the same way, exhausts its 3 retries, and the monitor dies. + +Net: the platform user gets the acknowledgement and then silence, until a server restart lets `recoverThread`'s found-0 branch restart the turn. The same hole opens if a user deletes the NTBS thread from the T3 UI mid-run (`deleteByThreadId` removes all turn rows). + +Fix direction, in the single helper from S2: treat found-0 as a state, not a violation. Load the thread; session `null`/`starting`/`running` → still pending (return null / `stats: null`); session settled → terminal failure outcome ("T3 could not start work on this request", with `session.lastError`); thread gone from the projection → cancellation. Posting a response flips the record to `thread.response.posted`, so restart-recovery correctly won't retry it. The live path should _post failure_, not restart the turn — restarting on a spawn failure would loop; the bounded once-per-boot retry in recovery is the right place for retries to live. + +**B2. Startup race: a request processed while recovery loads gets two monitors** — `processor.ts:1092-1115`, `912-981` +`loadThreadsAwaitingResponse` snapshots all `thread.created` records after the subscription starts. Any request that `process` handled before that load — record saved, monitor forked, still running — is also in the recovery list, so `recoverThread` sets a fresh baseline into the shared `messageStatus` entry and forks a **second** monitor for the same message. Consequences are contained (the outcome lock and state checks prevent double posting) but real: duplicated polling, and when the turn finishes, whichever monitor deletes the map entry first makes the other's next `checkProgress` fail with "No monitoring state exists" → 3 futile retries → a logged-error death that looks like a genuine failure. + +There's also a narrower cousin: `recoverThread`'s found-0 branch can re-dispatch `thread.turn.start` if the recovery load lands in the small window between `adapter.save` and `startT3Turn` inside `process` (`processor.ts:1039-1044`). Same messageId, so the projection largely coalesces it, but it's the same root cause. + +Fix: guard recovery internally — skip records whose `sourceUri` is in `inFlightRequests` (covers the save→ack span) or whose message is in the active-monitor `Set` from S1 (covers the rest of the turn's lifetime). Note that "wire startup so recovery finishes before webhooks go live" is _not_ currently expressible: `subscribeToT3Events` never returns, and nothing signals recovery completion. The internal guard avoids inventing that signal. + +**B3. The 3-minute no-progress timeout will kill healthy turns** — `processor.ts:734-736` +`12 × 15s` of no _projected_ progress interrupts the turn. `getTurnStats`'s own doc comment concedes the limitation: buffered output, hidden reasoning, and provider work that produces no projected event are invisible. The concrete everyday case: a single long tool execution — an install, build, or test suite taking >3 minutes — projects an activity when the call starts and then nothing until it returns, with no assistant text streaming in between. The monitor will interrupt mid-build and post a timeout for a turn that was fine. The `TODO: config` is already there; beyond making it configurable, the default needs to be sized for agent work (10+ minutes), because there is no cheap signal that distinguishes "provider hung" from "tool call still running" at this altitude. + +**B4. Monitor death is permanent and quiet** — `processor.ts:743-754` +A transient projection failure lasting ~45s (4 attempts, 15s apart) kills the monitor for that request; only a log line records it. Outcome posting still works via the event path, so the visible loss is just stall protection — but that's precisely the protection you can't tell is missing. Consider retrying the _load_ indefinitely with backoff and reserving monitor death for the genuinely-inconsistent-state errors. Low urgency, cheap to do while implementing B1 (which removes the most common source of these deaths). + +**B5. Crash window between thread creation and `adapter.save` orphans a thread** — `processor.ts:1025-1041` +The architecture doc's original lifecycle persisted an `accepted` state _before_ touching T3; the simplified lifecycle (deliberately, and I agree with the cut) saves only after `createT3Thread` succeeds. Cost: a crash in that window leaves a T3 thread + worktree with no adapter record, and the redelivery creates a second thread. That's acceptable at-least-once behavior — but it contradicts the plan doc's "persist the accepted lifecycle state before starting T3 work", so record the decision in the `processor.ts` header comment (or the plan doc) so it reads as chosen, not missed. + +**B6. Recovery assumes the turn projection is caught up at startup** — `processor.ts:917-931` +If projections hydrate asynchronously relative to when wiring calls `subscribeToT3Events`, `recoverThread` can read a stale 0-turn view and re-dispatch `thread.turn.start`. Reusing the recorded `userMessageId` makes this nearly idempotent, but two adopted turns for one message would trip every "exactly one" check and wedge the request. Since the wiring doesn't exist yet, this is a one-line requirement to write down wherever the processor gets started: projection catch-up happens-before `subscribeToT3Events`. + +**B7. Head-of-line blocking on the event loop** — `processor.ts:1079-1090` +`Stream.runForEach` processes session-set events sequentially, and `processT3Event` holds the event loop through adapter lookups and — under the outcome lock — platform API calls. One slow Discord/Jira call delays outcome posting for every other request on the same adapter (adapters are isolated from each other; each has its own processor). Fine for v1 volumes; worth a comment so the serialization is visibly a choice, and the fix (fork per event once it matters) is understood. + +Related, no action needed: `postResponse` posting successfully and then failing to `save` leaves the record `thread.created` with no further session-set to retry it in-lifetime; restart-recovery resolves it via `findMatchingResponseMessage` without re-posting. That's the designed net and it holds. + +--- + +## Reviewed and deliberately not flagged + +- `ensureUniqueOutcome` + per-message semaphore + `markResponsePosted` cleanup: correct, including the re-created-lock guard on delete. +- Subscription-before-recovery ordering in `subscribeToT3Events`: right pattern for a hot stream; no missed-event window. +- `resolveWorktreeBase`: fetch-failure and unresolvable-ref fallbacks are sensible, and "never fails, let worktree creation carry the real git error" is the right call. +- Worktree cleanup on `thread.create` failure, including the documented accepted leak of the temporary branch ref. +- `findMatchingResponseMessage` consulted on every post: cheap, and it's the idempotency net for the post-then-crash window — keep it in the common path. +- Posting the timeout response before interrupt confirmation (a late real answer gets discarded): a defensible product trade-off, already serialized correctly. From 11c8c9def94ef8995d5b2971c2ab9045d90191b2 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 22:08:09 +0200 Subject: [PATCH 063/110] test: note missing-turn recovery coverage --- apps/server/src/ntbs/processor.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index 5e308186c604..e3188e592bba 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -80,6 +80,9 @@ processes a new request into a T3 thread, starts its first turn, persists lifecy - runs setup; - posts the acknowledgement; - does not duplicate work. + + TODO: Add a recovery-path test for a stored thread with no matching projected + turn; recovery should start the original turn and monitor it. */ /* From ad761119142a4420de6f6340dcf70e08ee7c4c13 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 22:15:57 +0200 Subject: [PATCH 064/110] refactor: simplify NTBS turn monitoring --- .vscode/settings.json | 3 +- apps/server/src/ntbs/processor.ts | 70 ++++++++++++------------------- docs/planning/ntbs-todos.md | 23 ++++++++++ 3 files changed, 51 insertions(+), 45 deletions(-) create mode 100644 docs/planning/ntbs-todos.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c426dce5918..55c479eb48cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,6 @@ }, "search.exclude": { ".repos/**": true - } + }, + "js/ts.experimental.useTsgo": true } diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 7063bad68bb6..a3323f713fcb 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -627,14 +627,6 @@ export const makeNTBSProcessor = ( */ const inFlightRequests = new Set(); - /** - * Keeps stats of active NTBS messages. - * - * Used to find out whether a turn has progressed since last check - * or is it hanging. - */ - const messageStatus = new Map(); - /** * Fetches fresh information for the turn created by one T3 user message. */ @@ -686,46 +678,30 @@ export const makeNTBSProcessor = ( /** * Loads the current turn status and compares it with the previous observation. * The status recorded when monitoring begins is the initial baseline. - * Nonterminal observations replace the stored baseline; terminal observations remove it. */ const checkProgress = ( userMessageId: MessageId, + previousStatus: TurnStatus, ): Effect.Effect< { readonly status: TurnStatus; readonly progressed: boolean }, NTBSProcessorError > => Effect.gen(function* () { - const recorded = messageStatus.get(userMessageId); - if (recorded === undefined) { - return yield* new NTBSProcessorError({ - reason: `No monitoring state exists for T3 user message ${userMessageId}.`, - cause: userMessageId, - }); - } - - const fresh = yield* loadMessageStatus(userMessageId, recorded.threadId); + const fresh = yield* loadMessageStatus(userMessageId, previousStatus.threadId); let progressed: boolean; - if (recorded.stats === null && fresh.stats === null) { + if (previousStatus.stats === null && fresh.stats === null) { progressed = false; - } else if (recorded.stats === null) { + } else if (previousStatus.stats === null) { progressed = true; } else if (fresh.stats === null) { return yield* new NTBSProcessorError({ - reason: `T3 thread ${recorded.threadId} became pending after its turn had started.`, - cause: { recorded, fresh }, + reason: `T3 thread ${previousStatus.threadId} became pending after its turn had started.`, + cause: { previousStatus, fresh }, }); } else { - progressed = hasProgress(recorded.stats, fresh.stats); - } - - const finished = fresh.stats !== null && fresh.stats.state !== "running"; - - if (finished) { - messageStatus.delete(userMessageId); - } else { - messageStatus.set(userMessageId, fresh); + progressed = hasProgress(previousStatus.stats, fresh.stats); } return { status: fresh, progressed }; @@ -735,12 +711,17 @@ export const makeNTBSProcessor = ( const CHECK_INTERVAL = "15 seconds"; const MAX_NO_PROGRESS_CHECKS = 12; - const monitorT3Turn = (userMessageId: MessageId): Effect.Effect => + const monitorT3Turn = ( + userMessageId: MessageId, + initialStatus: TurnStatus, + ): Effect.Effect => Effect.gen(function* () { let consecutiveNoProgressChecks = 0; + let previousStatus = initialStatus; + while (true) { - const result = yield* checkProgress(userMessageId).pipe( + const result = yield* checkProgress(userMessageId, previousStatus).pipe( Effect.retry({ times: 3, schedule: Schedule.spaced(CHECK_INTERVAL), @@ -753,6 +734,8 @@ export const makeNTBSProcessor = ( ), ); + previousStatus = result.status; + const stats = result.status.stats; if (stats !== null && stats.state !== "running") { @@ -774,7 +757,7 @@ export const makeNTBSProcessor = ( yield* Effect.sleep(CHECK_INTERVAL); } - }).pipe(Effect.ensuring(Effect.sync(() => messageStatus.delete(userMessageId)))); + }); /** * Creates an isolated worktree and a new T3 thread. @@ -927,6 +910,7 @@ export const makeNTBSProcessor = ( } const turn = matchingTurns[0]; + let initialStatus: TurnStatus; if (turn === undefined) { yield* startT3Turn( @@ -935,14 +919,14 @@ export const makeNTBSProcessor = ( threadCreated.snapshot, threadCreated.attachments, ); - messageStatus.set(userMessageId, { - threadId, + initialStatus = { recordedAt: yield* getNow, + threadId, stats: null, - }); + }; } else { const status = yield* loadMessageStatus(userMessageId, threadId); - + initialStatus = status; if (status.stats !== null && status.stats.state !== "running") { yield* ensureUniqueOutcome( userMessageId, @@ -964,11 +948,9 @@ export const makeNTBSProcessor = ( ); return; } - - messageStatus.set(userMessageId, status); } - yield* monitorT3Turn(userMessageId).pipe( + yield* monitorT3Turn(userMessageId, initialStatus).pipe( Effect.catch((cause) => Effect.logError("Recovered NTBS turn monitor failed", { userMessageId, @@ -1042,12 +1024,12 @@ export const makeNTBSProcessor = ( // Start the first T3 turn with that message Id, the snapshot and attachments yield* startT3Turn(threadId, userMessageId, request.snapshot, request.attachments); - messageStatus.set(userMessageId, { + + yield* monitorT3Turn(userMessageId, { threadId, recordedAt: yield* getNow, stats: null, - }); - yield* monitorT3Turn(userMessageId).pipe( + }).pipe( Effect.catch((cause) => Effect.logError("NTBS turn monitor failed", { userMessageId, diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md new file mode 100644 index 000000000000..4e8860696c46 --- /dev/null +++ b/docs/planning/ntbs-todos.md @@ -0,0 +1,23 @@ +# Competing outcome-drivers + +Outcome drivers are APIs that handle the turn end + +In `processor.ts` right now, both `monitorT3Turn` and `processT3Event` are competing for the same turn projection. + +The first one has a poll-based mechanism. After a turn starts, the polling checks changes and attempts to detect terminal state. + +The second one listens for `thread.session-set` events emitted by the T3 orchestration engine. + +We should analyze this issue and decide which one to keep. + +The important simplification is not which one wins; it is that terminal outcome response has a single owner. + +# Is the whole NTBS contract a state machine under disguise? + +`NTBSAdapter.save` is a generic write: "store this record, whatever it is". + +Nothing enforces/prevents bad transitions (e.g. `ResponsePosted -> ThreadCreated`) or two records of the same external request. + +A byproduct of this is that lots of choreography is shifted as a responsibility of the processor itself which has to continuously ask whether it's not dealing with a dedup and such. + +Idea: could `save` be instead replaced with a proper state machine that uniquely indexed on the request URI? From 012f0ed7490d6e484af01417e0d4671d3d594915 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 22:28:42 +0200 Subject: [PATCH 065/110] chore: remove pointless recordedAt --- apps/server/src/ntbs/processor.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index a3323f713fcb..57356703e202 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -190,8 +190,6 @@ const hasProgress = (previous: TurnStats, current: TurnStats): boolean => type TurnStatus = { /** The T3 thread containing the monitored user message. */ readonly threadId: ThreadId; - /** When the processor read this status from the T3 projection. */ - readonly recordedAt: string; /** The observed turn statistics, or null while the turn is still pending. */ readonly stats: TurnStats | null; }; @@ -920,7 +918,6 @@ export const makeNTBSProcessor = ( threadCreated.attachments, ); initialStatus = { - recordedAt: yield* getNow, threadId, stats: null, }; @@ -1027,7 +1024,6 @@ export const makeNTBSProcessor = ( yield* monitorT3Turn(userMessageId, { threadId, - recordedAt: yield* getNow, stats: null, }).pipe( Effect.catch((cause) => From 4f951edc5b0dcef74f5e0b3a1631b1997a947fc5 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 23:01:40 +0200 Subject: [PATCH 066/110] feat: simplify turn status --- apps/server/src/ntbs/processor.ts | 62 ++++++++++++++----------------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 57356703e202..f5ab3ccaa1ec 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -130,8 +130,6 @@ type NTBSProcessorRequirements = | Crypto.Crypto; type TurnStats = { - readonly turnId: TurnId; - readonly state: OrchestrationLatestTurnState; readonly activityCount: number; readonly latestActivityId: EventId | null; readonly assistantTextLength: number; @@ -162,8 +160,6 @@ const getTurnStats = ( ); return { - turnId: turn.turnId, - state: turn.state, activityCount: activities.length, latestActivityId: activities.at(-1)?.id ?? null, assistantTextLength: assistantMessages.reduce( @@ -180,19 +176,17 @@ const hasProgress = (previous: TurnStats, current: TurnStats): boolean => previous.assistantTextLength !== current.assistantTextLength || previous.assistantUpdatedAt !== current.assistantUpdatedAt; -/** - * Records what the processor observed when it last checked a T3 turn. - * - * `stats` is null while T3 has accepted the turn request but the provider has - * not started the turn. Once the turn exists, `stats.state` is the single - * source of truth for whether it is running or terminal. - */ -type TurnStatus = { - /** The T3 thread containing the monitored user message. */ - readonly threadId: ThreadId; - /** The observed turn statistics, or null while the turn is still pending. */ - readonly stats: TurnStats | null; -}; +type TurnStatus = + | { + readonly threadId: ThreadId; + state: "pending"; + } + | { + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly state: OrchestrationLatestTurnState; + readonly stats: TurnStats; + }; /** * Creates an NTBS processor for one adapter. @@ -515,13 +509,13 @@ export const makeNTBSProcessor = ( */ const handleStalledTurn = ( userMessageId: MessageId, - status: TurnStatus, + turn: TurnStatus, ): Effect.Effect => ensureUniqueOutcome( userMessageId, Effect.gen(function* () { const lifecycle = yield* adapter - .findByThreadId(status.threadId) + .findByThreadId(turn.threadId) .pipe(orFail("Failed loading the NTBS lifecycle for a stalled turn")); if (lifecycle.state === "thread.response.posted") { @@ -529,13 +523,13 @@ export const makeNTBSProcessor = ( return; } - if (status.stats !== null) { - const turnId = status.stats.turnId; - yield* interruptT3Turn(status.threadId, turnId).pipe( + if (turn.state !== "pending") { + const turnId = turn.turnId; + yield* interruptT3Turn(turn.threadId, turnId).pipe( Effect.catch((cause) => Effect.logWarning("Failed interrupting stalled T3 turn", { userMessageId, - threadId: status.threadId, + threadId: turn.threadId, turnId, cause, }), @@ -546,7 +540,7 @@ export const makeNTBSProcessor = ( const response: NTBSResponse = { type: "timeout", text: - status.stats === null + turn.state === "pending" ? "T3 could not start this request after repeated checks." : "T3 stopped this request after repeated checks found no observable progress.", }; @@ -648,7 +642,7 @@ export const makeNTBSProcessor = ( } if (monitoredTurn.turnId === null && monitoredTurn.state === "pending") { - return { threadId, stats: null, recordedAt: yield* getNow }; + return { threadId, state: "pending" }; } if (monitoredTurn.turnId === null || monitoredTurn.state === "pending") { @@ -670,7 +664,7 @@ export const makeNTBSProcessor = ( turnId: monitoredTurn.turnId, state: monitoredTurn.state, }); - return { threadId, stats, recordedAt: yield* getNow }; + return { threadId, turnId: monitoredTurn.turnId, stats, state: monitoredTurn.state }; }); /** @@ -689,11 +683,11 @@ export const makeNTBSProcessor = ( let progressed: boolean; - if (previousStatus.stats === null && fresh.stats === null) { + if (previousStatus.state === "pending" && fresh.state === "pending") { progressed = false; - } else if (previousStatus.stats === null) { + } else if (previousStatus.state === "pending") { progressed = true; - } else if (fresh.stats === null) { + } else if (fresh.state === "pending") { return yield* new NTBSProcessorError({ reason: `T3 thread ${previousStatus.threadId} became pending after its turn had started.`, cause: { previousStatus, fresh }, @@ -734,9 +728,9 @@ export const makeNTBSProcessor = ( previousStatus = result.status; - const stats = result.status.stats; + const { status } = result; - if (stats !== null && stats.state !== "running") { + if (status.state !== "pending" && status.state !== "running") { // it has completed already return; } @@ -919,12 +913,12 @@ export const makeNTBSProcessor = ( ); initialStatus = { threadId, - stats: null, + state: "pending", }; } else { const status = yield* loadMessageStatus(userMessageId, threadId); initialStatus = status; - if (status.stats !== null && status.stats.state !== "running") { + if (status.state !== "pending" && status.state !== "running") { yield* ensureUniqueOutcome( userMessageId, Effect.gen(function* () { @@ -1024,7 +1018,7 @@ export const makeNTBSProcessor = ( yield* monitorT3Turn(userMessageId, { threadId, - stats: null, + state: "pending", }).pipe( Effect.catch((cause) => Effect.logError("NTBS turn monitor failed", { From 7136a612d25b9ee8bbf1b4ccc4b6917e1a378cdd Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 23:06:35 +0200 Subject: [PATCH 067/110] chore: simplify resolveT3Outcome --- apps/server/src/ntbs/processor.ts | 47 ++++++++--------------- docs/planning/ntbs-processor.cc-review.md | 6 --- 2 files changed, 17 insertions(+), 36 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index f5ab3ccaa1ec..16856ab90aea 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -337,10 +337,7 @@ export const makeNTBSProcessor = ( const resolveT3Outcome = ( threadId: ThreadId, userMessageId: MessageId, - ): Effect.Effect< - { readonly threadId: ThreadId; readonly response: NTBSResponse } | null, - NTBSProcessorError - > => + ): Effect.Effect => Effect.gen(function* () { const turns = yield* projectionTurnRepository .listByThreadId({ threadId }) @@ -376,34 +373,24 @@ export const makeNTBSProcessor = ( const text = assistantMessage?.text.trim() ?? ""; - return { - threadId, - response: - text.length > 0 - ? { type: "answer", text } - : { - type: "failure", - text: "T3 completed without producing a response.", - }, - }; + return text.length > 0 + ? { type: "answer", text } + : { + type: "failure", + text: "T3 completed without producing a response.", + }; } if (turn.state === "error") { return { - threadId, - response: { - type: "failure", - text: thread.session?.lastError ?? "T3 failed while processing this request.", - }, + type: "failure", + text: thread.session?.lastError ?? "T3 failed while processing this request.", }; } return { - threadId, - response: { - type: "cancellation", - text: "T3 stopped processing this request.", - }, + type: "cancellation", + text: "T3 stopped processing this request.", }; }); @@ -491,12 +478,12 @@ export const makeNTBSProcessor = ( return; } - const outcome = yield* resolveT3Outcome(threadId, userMessageId); - if (outcome === null) { + const response = yield* resolveT3Outcome(threadId, userMessageId); + if (response === null) { return; } - yield* postResponse(currentRecord, outcome.response); + yield* postResponse(currentRecord, response); }), ); }); @@ -931,9 +918,9 @@ export const makeNTBSProcessor = ( return; } - const outcome = yield* resolveT3Outcome(threadId, userMessageId); - if (outcome !== null) { - yield* postResponse(currentRecord, outcome.response); + const response = yield* resolveT3Outcome(threadId, userMessageId); + if (response !== null) { + yield* postResponse(currentRecord, response); } }), ); diff --git a/docs/planning/ntbs-processor.cc-review.md b/docs/planning/ntbs-processor.cc-review.md index 2b5bcd76b757..9f07ba762e74 100644 --- a/docs/planning/ntbs-processor.cc-review.md +++ b/docs/planning/ntbs-processor.cc-review.md @@ -23,12 +23,6 @@ If you keep any shared structure, its only genuine job is "is someone already mo **S2. Three copies of the turn-for-message lookup** — `processor.ts:353-365`, `646-658`, `917-929` `resolveT3Outcome`, `loadMessageStatus`, and `recoverThread` each do `listByThreadId` → filter on `pendingMessageId === userMessageId` → cardinality check with a hand-built error. Extract one `findTurnForMessage(threadId, userMessageId)` helper. This is also the single place to implement the found-0 handling from bug **B1** — three call sites collapse into one decision point. -**S3. `TurnStatus.recordedAt` is write-only** — `processor.ts:194` -Set in four places (`661`, `683`, `940`, `1047`), read nowhere. Drop the field and the `getNow` calls that feed it. With S1, `TurnStatus` shrinks to `{ threadId, stats }`. - -**S4. `resolveT3Outcome` echoes a `threadId` both callers already hold** — `processor.ts:348-350` -Both call sites (`processor.ts:502-507`, `959-961`) use only `outcome.response`. Return `NTBSResponse | null`. - **S5. `postAcknowledgement`'s return value is dead contract** — `adapter.ts:43` The processor discards it (`processor.ts:1061-1070`, `Effect.asVoid`), and the simplified lifecycle no longer stores an acknowledgement message ID. Make it `Effect`; an adapter that needs the ID for its own `findMatchingResponseMessage` heuristics can store it internally. Right now the signature promises a use that doesn't exist. From 7b728e9e319e75a4ff7b29c3d4f91f61356279df Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 23:09:17 +0200 Subject: [PATCH 068/110] chore: simplify acknowledgement --- apps/server/src/ntbs/adapter.ts | 2 +- apps/server/src/ntbs/processor.ts | 3 +-- apps/server/src/ntbs/processor2.test.ts | 3 +-- docs/planning/ntbs-processor.cc-review.md | 3 --- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 48e4ac9b3e96..39c9c16b4396 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -40,7 +40,7 @@ export interface NTBSAdapter { * * Returns the platform's identifier for the posted message. */ - readonly postAcknowledgement: (state: NTBS.ThreadCreated) => Effect.Effect; + readonly acknowledge: (state: NTBS.ThreadCreated) => Effect.Effect; /** * Posts the final T3 outcome at the response destination described * by the event. diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 16856ab90aea..a9c080b38d37 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1017,7 +1017,7 @@ export const makeNTBSProcessor = ( Effect.forkDetach, ); - yield* adapter.postAcknowledgement(threadCreated).pipe( + yield* adapter.acknowledge(threadCreated).pipe( Effect.catch((cause) => Effect.logWarning("Failed posting the NTBS acknowledgement", { userMessageId, @@ -1025,7 +1025,6 @@ export const makeNTBSProcessor = ( cause, }), ), - Effect.asVoid, ); } return; diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts index 00f1f48e3544..f097c04b9592 100644 --- a/apps/server/src/ntbs/processor2.test.ts +++ b/apps/server/src/ntbs/processor2.test.ts @@ -120,10 +120,9 @@ const AdapterFromState = Layer.effect( Effect.sync(() => { state.records.set(lifecycleEvent.t3Data.threadId, lifecycleEvent); }), - postAcknowledgement: (record) => + acknowledge: (record) => Effect.sync(() => { state.postedAcks.push(record); - return `ack-${state.postedAcks.length}`; }), postResponse: (record, response) => Effect.sync(() => { diff --git a/docs/planning/ntbs-processor.cc-review.md b/docs/planning/ntbs-processor.cc-review.md index 9f07ba762e74..2d906770f14f 100644 --- a/docs/planning/ntbs-processor.cc-review.md +++ b/docs/planning/ntbs-processor.cc-review.md @@ -23,9 +23,6 @@ If you keep any shared structure, its only genuine job is "is someone already mo **S2. Three copies of the turn-for-message lookup** — `processor.ts:353-365`, `646-658`, `917-929` `resolveT3Outcome`, `loadMessageStatus`, and `recoverThread` each do `listByThreadId` → filter on `pendingMessageId === userMessageId` → cardinality check with a hand-built error. Extract one `findTurnForMessage(threadId, userMessageId)` helper. This is also the single place to implement the found-0 handling from bug **B1** — three call sites collapse into one decision point. -**S5. `postAcknowledgement`'s return value is dead contract** — `adapter.ts:43` -The processor discards it (`processor.ts:1061-1070`, `Effect.asVoid`), and the simplified lifecycle no longer stores an acknowledgement message ID. Make it `Effect`; an adapter that needs the ID for its own `findMatchingResponseMessage` heuristics can store it internally. Right now the signature promises a use that doesn't exist. - **S6. Half of `test-helpers.ts` is dead code** — `test-helpers.ts:99-270` `TestEngine`, `TestAdapterState`, `TestAdapter`, and both derived layers are unexported and unused (only `createGitLayerMock` and `createAdapterRequest` are imported, by `processor.test.ts`). `processor2.test.ts` contains its own — already divergent — copies of the same fakes. Pick one harness (the `processor2.test.ts` one is the better design: state services + `Layer.provideMerge`, and the `threadLookups` queue-as-synchronization trick is good), move it into `test-helpers.ts`, and delete the rest. Fold `processor.test.ts` into the same file while at it: its single test (`processor.test.ts:153-163`) asserts nothing — it passes if `process` doesn't die — and the `eventReceived` Deferred in its adapter is never awaited. The assertions it was meant to make are already enumerated in `docs/planning/processor-testing.md` steps 1–8; write them against the surviving harness. From 0e5b6768e5ccb85ac2ab18a58f1bc651f322c88e Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 14 Aug 2026 23:48:08 +0200 Subject: [PATCH 069/110] chore: refactor process --- apps/server/src/ntbs/processor.ts | 6 +----- docs/planning/ntbs-processor.cod-review.md | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index a9c080b38d37..9577a5f48cfc 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -952,8 +952,7 @@ export const makeNTBSProcessor = ( 5. Start monitoring the turn in the background. 6. Attempt to post the acknowledgement independently. */ - - const processAdapterRequest = (request: NTBS.NTBSInput, t3Context: T3Context) => + const process = (request: NTBS.NTBSInput, t3Context: T3Context) => Effect.gen(function* () { /* In-flight dedup first. We check if the processor is *currently* @@ -1031,9 +1030,6 @@ export const makeNTBSProcessor = ( }).pipe(Effect.ensuring(Effect.sync(() => inFlightRequests.delete(key)))); }); - const process = (request: NTBS.NTBSInput, t3Context: T3Context) => - processAdapterRequest(request, t3Context); - const consumeT3Events = Stream.runForEach( orchestrationEngineService.streamDomainEvents, (event) => diff --git a/docs/planning/ntbs-processor.cod-review.md b/docs/planning/ntbs-processor.cod-review.md index 1d0d7e524c22..12dd98219979 100644 --- a/docs/planning/ntbs-processor.cod-review.md +++ b/docs/planning/ntbs-processor.cod-review.md @@ -38,11 +38,7 @@ The outbound half should likewise be one adapter operation, for example `postRes These cuts are mechanical and do not change the design: -- `process` is only an alias for `processAdapterRequest` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1076)); keep one name. -- `resolveT3Outcome` returns `{ threadId, response }`, but every caller already has the thread ID and uses only `response` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L345)). Return `NTBSResponse | null`. -- `TurnStatus.recordedAt` is written on every observation and never read ([processor.ts](../../apps/server/src/ntbs/processor.ts#L190)). Remove it unless the timeout is changed to elapsed-time semantics. - The exact-turn lookup and its “exactly one” error are duplicated in `resolveT3Outcome` and `loadMessageStatus` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L353), [processor.ts](../../apps/server/src/ntbs/processor.ts#L646)). Extract one `loadRequestTurn(threadId, userMessageId)` helper. -- `postAcknowledgement` returns a platform message ID that is discarded ([adapter.ts](../../apps/server/src/ntbs/adapter.ts#L35), [processor.ts](../../apps/server/src/ntbs/processor.ts#L1061)). Return `void` unless acknowledgement recovery is going to persist that ID. - `makeNTBSProcessorTag` has no consumer except the test harness. The factory already returns the service value, so the extra tag factory can wait until production wiring demonstrates a need. `makeNTBSAdapterTag` remains useful if several adapter-specific processor layers are actually constructed. `getTurnStats` should stay conservative for now. Some of its fields look correlated, but removing activity count, last activity ID, assistant length, or update time without first checking provider projection behavior would weaken stall detection for little benefit. From 7014ba16163c0ee89ec2e57be4e4d42310b55d2f Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 15 Aug 2026 15:08:31 +0200 Subject: [PATCH 070/110] chore: implement feedback --- apps/server/src/ntbs/processor.test.ts | 2 +- apps/server/src/ntbs/processor.ts | 164 +++++++++++----------- apps/server/src/ntbs/processor2.test.ts | 4 +- apps/server/src/ntbs/test-helpers.ts | 2 +- docs/planning/ntbs-processor.cc-review.md | 14 -- 5 files changed, 84 insertions(+), 102 deletions(-) diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index e3188e592bba..649abb322bab 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -24,7 +24,7 @@ const makeTestAdapter = Effect.gen(function* () { const service: NTBSAdapter = { save: () => Effect.void, - postAcknowledgement: () => Effect.succeed("acknowledgement id"), + acknowledge: () => Effect.succeed("acknowledgement id"), postResponse: () => Effect.succeed("response id"), findByRequest: () => Effect.succeed(null), findMatchingResponseMessage: () => Effect.succeed(null), diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 9577a5f48cfc..35d2238223f2 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -88,7 +88,7 @@ export interface NTBSProcessor { ) => Effect.Effect; /** - * Consumes T3 events and passes them to `processT3Event`. + * The main loop of the processor, consumes T3 events and passes them to `processT3Event`. * * After the live subscription begins, loads stored `ThreadCreated` records. * It starts a missing first turn, resumes monitoring an active turn, or posts @@ -97,7 +97,7 @@ export interface NTBSProcessor { * Runs until interrupted by its caller. * Logs individual processing failures and continues with later events. */ - readonly subscribeToT3Events: Effect.Effect; + readonly start: Effect.Effect; } export const makeNTBSProcessorTag = (key: string) => Context.Service(key); @@ -330,6 +330,22 @@ export const makeNTBSProcessor = ( .pipe(orFail(`Failed to interrupt T3 turn ${turnId}`)); }); + const getTurn = (threadId: ThreadId, userMessageId: MessageId) => + Effect.gen(function* () { + const turns = yield* projectionTurnRepository + .listByThreadId({ threadId }) + .pipe(orFail(`Failed loading turns for T3 thread ${threadId}`)); + /* + Using `.find` is safe: a userMessageId can never label more than one turn. + The UUID is minted once per request, and a turn start is only repeated + (by recovery) when no turn exists for it. + If the turn started, `.find` finds it. Finding none means the turn never + started (e.g. crash) or T3 discarded it before a provider picked it up. + */ + const turn = turns.find((turn) => turn.pendingMessageId === userMessageId); + return turn ?? null; + }); + /** * Reads the final outcome of the turn started by one NTBS user message. * Returns `null` while that exact turn is still pending or running. @@ -339,17 +355,12 @@ export const makeNTBSProcessor = ( userMessageId: MessageId, ): Effect.Effect => Effect.gen(function* () { - const turns = yield* projectionTurnRepository - .listByThreadId({ threadId }) - .pipe(orFail(`Failed loading turns for T3 thread ${threadId}`)); - - const matchingTurns = turns.filter((turn) => turn.pendingMessageId === userMessageId); - const turn = matchingTurns[0]; + const turn = yield* getTurn(threadId, userMessageId); - if (matchingTurns.length !== 1 || turn === undefined) { + if (!turn) { return yield* new NTBSProcessorError({ - reason: `Expected exactly one T3 turn for user message ${userMessageId}, found ${matchingTurns.length}.`, - cause: { threadId, userMessageId, matchingTurns }, + reason: `Turn for user message ${userMessageId} not found.`, + cause: { threadId, userMessageId }, }); } @@ -614,28 +625,26 @@ export const makeNTBSProcessor = ( threadId: ThreadId, ): Effect.Effect => Effect.gen(function* () { - const turns = yield* projectionTurnRepository - .listByThreadId({ threadId }) - .pipe(orFail(`Could not load projected turns for T3 thread ${threadId}`)); - - const matchingTurns = turns.filter((turn) => turn.pendingMessageId === userMessageId); - const monitoredTurn = matchingTurns[0]; + const turn = yield* getTurn(threadId, userMessageId); - if (matchingTurns.length !== 1 || monitoredTurn === undefined) { + if (!turn) { return yield* new NTBSProcessorError({ - reason: `Expected exactly one T3 turn for user message ${userMessageId}, found ${matchingTurns.length}.`, - cause: { userMessageId, threadId, matchingTurns }, + reason: `Failed to retrieve turn for user message`, + cause: { userMessageId, threadId }, }); } - if (monitoredTurn.turnId === null && monitoredTurn.state === "pending") { + if (turn.turnId === null && turn.state === "pending") { return { threadId, state: "pending" }; } - if (monitoredTurn.turnId === null || monitoredTurn.state === "pending") { + // NOTE: This is not a business-logic related check. + // Turn state *in practice* has always turnId === null and state === pending + // But since they live on different properties we need to make it typecheck and cross check the wire + if (turn.turnId === null || turn.state === "pending") { return yield* new NTBSProcessorError({ reason: `T3 turn state is inconsistent for user message ${userMessageId}.`, - cause: monitoredTurn, + cause: turn, }); } @@ -648,10 +657,10 @@ export const makeNTBSProcessor = ( ); const stats = getTurnStats(thread, { - turnId: monitoredTurn.turnId, - state: monitoredTurn.state, + turnId: turn.turnId, + state: turn.state, }); - return { threadId, turnId: monitoredTurn.turnId, stats, state: monitoredTurn.state }; + return { threadId, turnId: turn.turnId, stats, state: turn.state }; }); /** @@ -876,22 +885,11 @@ export const makeNTBSProcessor = ( ): Effect.Effect => Effect.gen(function* () { const { threadId, userMessageId } = threadCreated.t3Data; - const turns = yield* projectionTurnRepository - .listByThreadId({ threadId }) - .pipe(orFail(`Failed loading turns while recovering T3 thread ${threadId}`)); - const matchingTurns = turns.filter((turn) => turn.pendingMessageId === userMessageId); - - if (matchingTurns.length > 1) { - return yield* new NTBSProcessorError({ - reason: `Expected at most one T3 turn for user message ${userMessageId}, found ${matchingTurns.length}.`, - cause: { threadId, userMessageId, matchingTurns }, - }); - } - const turn = matchingTurns[0]; + const turn = yield* getTurn(threadId, userMessageId); let initialStatus: TurnStatus; - if (turn === undefined) { + if (!turn) { yield* startT3Turn( threadId, userMessageId, @@ -975,58 +973,56 @@ export const makeNTBSProcessor = ( // durable dedup const existingRequest = yield* adapter .findByRequest(request) - .pipe(orFail("Error getting the existing request in processAdapterRequest")); + .pipe(orFail("Error getting the existing request in process")); if (existingRequest) { return; - } else { - // create the worktree and T3 thread - const threadId = yield* createT3Thread(t3Context); + } + // create the worktree and T3 thread + const threadId = yield* createT3Thread(t3Context); - // generate the first user message ID and record it with ThreadCreated - const userMessageId = MessageId.make(yield* randomUUID); + // generate the first user message ID and record it with ThreadCreated + const userMessageId = MessageId.make(yield* randomUUID); - const threadCreated: NTBS.ThreadCreated = { - ...request, - state: "thread.created", - t3Data: { - threadId, - userMessageId, - }, - }; + const threadCreated: NTBS.ThreadCreated = { + ...request, + state: "thread.created", + t3Data: { + threadId, + userMessageId, + }, + }; - yield* adapter - .save(threadCreated) - .pipe(orFail("Failed to record the created NTBS thread")); + yield* adapter + .save(threadCreated) + .pipe(orFail("Failed to record the created NTBS thread")); - // Start the first T3 turn with that message Id, the snapshot and attachments - yield* startT3Turn(threadId, userMessageId, request.snapshot, request.attachments); + // Start the first T3 turn with that message Id, the snapshot and attachments + yield* startT3Turn(threadId, userMessageId, request.snapshot, request.attachments); - yield* monitorT3Turn(userMessageId, { - threadId, - state: "pending", - }).pipe( - Effect.catch((cause) => - Effect.logError("NTBS turn monitor failed", { - userMessageId, - threadId, - cause, - }), - ), - Effect.forkDetach, - ); + yield* monitorT3Turn(userMessageId, { + threadId, + state: "pending", + }).pipe( + Effect.catch((cause) => + Effect.logError("NTBS turn monitor failed", { + userMessageId, + threadId, + cause, + }), + ), + Effect.forkDetach, + ); - yield* adapter.acknowledge(threadCreated).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed posting the NTBS acknowledgement", { - userMessageId, - threadId, - cause, - }), - ), - ); - } - return; + yield* adapter.acknowledge(threadCreated).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed posting the NTBS acknowledgement", { + userMessageId, + threadId, + cause, + }), + ), + ); }).pipe(Effect.ensuring(Effect.sync(() => inFlightRequests.delete(key)))); }); @@ -1068,7 +1064,7 @@ export const makeNTBSProcessor = ( ), ); - const subscribeToT3Events = Effect.scoped( + const start = Effect.scoped( Effect.gen(function* () { yield* consumeT3Events.pipe(Effect.forkScoped({ startImmediately: true })); yield* recoverStoredThreads; @@ -1078,6 +1074,6 @@ export const makeNTBSProcessor = ( return { process, - subscribeToT3Events, + start, }; }); diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts index f097c04b9592..cd4230d35d83 100644 --- a/apps/server/src/ntbs/processor2.test.ts +++ b/apps/server/src/ntbs/processor2.test.ts @@ -219,7 +219,7 @@ describe("NTBSProcessor (layer harness)", () => { const adapterState = yield* TestAdapterState; const processor = yield* TestProcessor; - yield* processor.subscribeToT3Events.pipe(Effect.forkChild({ startImmediately: true })); + yield* processor.start.pipe(Effect.forkChild({ startImmediately: true })); const threadId = ThreadId.make("unknown-thread"); yield* engine.publish(yield* sessionSetEvent(threadId)); @@ -267,7 +267,7 @@ describe("NTBSProcessor (layer harness)", () => { responseMessageId: "already-posted", }); - yield* processor.subscribeToT3Events.pipe(Effect.forkChild({ startImmediately: true })); + yield* processor.start.pipe(Effect.forkChild({ startImmediately: true })); yield* engine.publish(yield* sessionSetEvent(threadId)); // The processor loads the record twice: once to route the event and once diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts index 05c64c6d0674..36c0f3c7651e 100644 --- a/apps/server/src/ntbs/test-helpers.ts +++ b/apps/server/src/ntbs/test-helpers.ts @@ -216,7 +216,7 @@ const TestAdapterFromState = Layer.effect( Effect.sync(() => { adapterState.records.set(event.t3Data.threadId, event); }), - postAcknowledgement: (state) => + acknowledge: (state) => Effect.sync(() => { const acknowledgementId = `acknowledgementId-${adapterState.postedAcks.size}`; adapterState.postedAcks.set(acknowledgementId, state); diff --git a/docs/planning/ntbs-processor.cc-review.md b/docs/planning/ntbs-processor.cc-review.md index 2d906770f14f..78218b18fcd9 100644 --- a/docs/planning/ntbs-processor.cc-review.md +++ b/docs/planning/ntbs-processor.cc-review.md @@ -11,15 +11,6 @@ Overall the shape is right: the processor/adapter boundary is clean, the outcome ### API and business logic -**S1. `messageStatus` is fiber-local state wearing a Map costume** — `processor.ts:636` -The map has exactly one reader, `checkProgress` (`processor.ts:698`); every other use is a write or delete from the same monitor's own control flow. The progress baseline can live in a local variable inside `monitorT3Turn`, with the initial `TurnStatus` passed in as a parameter. That deletes: - -- the `"No monitoring state exists"` error case (`processor.ts:700-704`), which cannot occur except through the map indirection itself; -- the delete/set baseline dance in `checkProgress` (`processor.ts:725-729`) and the second delete in the monitor's `ensuring` (`processor.ts:777`); -- the `TurnStats` / `TurnStatus` name near-collision, since `TurnStatus` mostly dissolves. - -If you keep any shared structure, its only genuine job is "is someone already monitoring this message" — a `Set` — which is exactly what bug **B2** needs. So: baseline goes fiber-local, the map becomes a Set used for recovery dedup. - **S2. Three copies of the turn-for-message lookup** — `processor.ts:353-365`, `646-658`, `917-929` `resolveT3Outcome`, `loadMessageStatus`, and `recoverThread` each do `listByThreadId` → filter on `pendingMessageId === userMessageId` → cardinality check with a hand-built error. Extract one `findTurnForMessage(threadId, userMessageId)` helper. This is also the single place to implement the found-0 handling from bug **B1** — three call sites collapse into one decision point. @@ -28,15 +19,10 @@ If you keep any shared structure, its only genuine job is "is someone already mo **S7. Small cuts** -- `process` is a rename of `processAdapterRequest` (`processor.ts:1076-1077`) — define `process` directly. -- `else` + trailing `return` after the early-return dedup branch (`processor.ts:1021-1073`) — flatten. - `runtimeMode: "full-access"` + `interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE` are hardcoded twice (`processor.ts:309-310`, `847-848`) — one module-level constant pair, which is also where a future per-request override would land. ### Naming and contracts -**N1. `subscribeToT3Events` undersells itself** — `processor.ts:100`, `1117-1123` -It subscribes, recovers stored threads, forks monitors, and then runs forever. Callers wire it as the processor's main loop. Call it `run` (or `start`), and keep the doc comment as is — the comment is accurate, the name isn't. - **N2. `ThreadEvent` is not an event** — `lifecycle.ts:39` It's the stored record shape (input + T3 ids); the states are `ThreadCreated`/`ResponsePosted` and the union is already correctly named `NTBSLifecycle`. `ThreadRecord` (or `LifecycleBase`) says what it is. Same file: the fields are mutable while everything in `processor.ts` is `readonly` — make the contract types `readonly` too. From dc0f90c710d830b0763e185417cdda47d58cc531 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 15 Aug 2026 15:39:18 +0200 Subject: [PATCH 071/110] docs: update NTBS processor review --- docs/planning/ntbs-processor.cc-review.md | 63 ++++++++++++----------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/docs/planning/ntbs-processor.cc-review.md b/docs/planning/ntbs-processor.cc-review.md index 78218b18fcd9..ac6a649d52bc 100644 --- a/docs/planning/ntbs-processor.cc-review.md +++ b/docs/planning/ntbs-processor.cc-review.md @@ -1,6 +1,6 @@ # NTBS directory review -**Status:** review notes (Claude Code, 2026-08-14) +**Status:** review notes (Claude Code, 2026-08-14; updated 2026-08-15 — addressed items removed, numbering gaps are fixes that already landed) **Scope:** `apps/server/src/ntbs/` — `processor.ts`, `adapter.ts`, `lifecycle.ts`, `test-helpers.ts`, `processor.test.ts`, `processor2.test.ts` — checked against the projection pipeline, decider, provider runtime ingestion, and the planning docs. Overall the shape is right: the processor/adapter boundary is clean, the outcome lock design is sound, and the recovery model (durable record + turn lookup + `findMatchingResponseMessage`) handles the crash windows it was designed for. The findings below are refinements, ordered by how much I'd want them fixed. @@ -11,15 +11,12 @@ Overall the shape is right: the processor/adapter boundary is clean, the outcome ### API and business logic -**S2. Three copies of the turn-for-message lookup** — `processor.ts:353-365`, `646-658`, `917-929` -`resolveT3Outcome`, `loadMessageStatus`, and `recoverThread` each do `listByThreadId` → filter on `pendingMessageId === userMessageId` → cardinality check with a hand-built error. Extract one `findTurnForMessage(threadId, userMessageId)` helper. This is also the single place to implement the found-0 handling from bug **B1** — three call sites collapse into one decision point. - **S6. Half of `test-helpers.ts` is dead code** — `test-helpers.ts:99-270` -`TestEngine`, `TestAdapterState`, `TestAdapter`, and both derived layers are unexported and unused (only `createGitLayerMock` and `createAdapterRequest` are imported, by `processor.test.ts`). `processor2.test.ts` contains its own — already divergent — copies of the same fakes. Pick one harness (the `processor2.test.ts` one is the better design: state services + `Layer.provideMerge`, and the `threadLookups` queue-as-synchronization trick is good), move it into `test-helpers.ts`, and delete the rest. Fold `processor.test.ts` into the same file while at it: its single test (`processor.test.ts:153-163`) asserts nothing — it passes if `process` doesn't die — and the `eventReceived` Deferred in its adapter is never awaited. The assertions it was meant to make are already enumerated in `docs/planning/processor-testing.md` steps 1–8; write them against the surviving harness. +`TestEngine`, `TestAdapterState`, `TestAdapter`, and both derived layers are unexported and unused (only `createGitLayerMock` and `createAdapterRequest` are imported, by `processor.test.ts`). `processor2.test.ts` contains its own — already divergent — copies of the same fakes. Pick one harness (the `processor2.test.ts` one is the better design: state services + `Layer.provideMerge`, and the `threadLookups` queue-as-synchronization trick is good), move it into `test-helpers.ts`, and delete the rest. Fold `processor.test.ts` into the same file while at it: its single test (`processor.test.ts:156-166`) asserts nothing — it passes if `process` doesn't die — and the `eventReceived` Deferred in its adapter is never awaited. The assertions it was meant to make are already enumerated in `docs/planning/processor-testing.md` steps 1–8; write them against the surviving harness. **S7. Small cuts** -- `runtimeMode: "full-access"` + `interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE` are hardcoded twice (`processor.ts:309-310`, `847-848`) — one module-level constant pair, which is also where a future per-request override would land. +- `runtimeMode: "full-access"` + `interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE` are hardcoded twice (`processor.ts:301-302`, `818-819`) — one module-level constant pair, which is also where a future per-request override would land. ### Naming and contracts @@ -28,67 +25,75 @@ It's the stored record shape (input + T3 ids); the states are `ThreadCreated`/`R **N3. Misleading or stale comments** -- `processor.ts:477-480`: "we're only interested in the last user message that appears in the adapter records" — it's the _original_ user message recorded for the request, and it's the only one the adapter knows. "Last" implies a selection that doesn't happen. -- `lifecycle.ts:32-36`: "The processor creates them from attachment data provided by the adapter" — the processor passes `attachments` through untouched (`processor.ts:1044`). The adapter creates them. +- `processor.ts:467-470`: "we're only interested in the last user message that appears in the adapter records" — it's the _original_ user message recorded for the request, and it's the only one the adapter knows. "Last" implies a selection that doesn't happen. +- `processor.ts:641-643`: the note on the inconsistency check says "Turn state _in practice_ has always turnId === null and state === pending" — as written it claims every turn is always unadopted, which is false. What it means: adoption sets `turnId` and leaves `"pending"` in one step, so `turnId === null ⇔ state === "pending"`; the mixed combos can't occur, and the check exists to narrow the type (and trip loudly on a corrupted projection). +- `adapter.ts:41`: `acknowledge` doc still says "Returns the platform's identifier for the posted message" — it returns `Effect` since the signature was simplified. +- `lifecycle.ts:32-35`: "The processor creates them from attachment data provided by the adapter" — the processor passes `attachments` through untouched. The adapter creates them. - `adapter.ts:48`: typo "idenitifier". **N4. `adapter.save` doesn't say it's an upsert or name its key** — `adapter.ts:33-36` The processor calls `save` twice per lifecycle (created, then posted) and expects the second write to replace the first. Both test adapters guessed "keyed by threadId". State it: "Upserts the record for this request; `sourceUri` (equivalently the T3 thread, they're 1:1) is the identity." -**N5. `snapshot`'s 120k limit names no enforcer** — `lifecycle.ts:27-31` +**N5. `snapshot`'s 120k limit names no enforcer** — `lifecycle.ts:26-30` The processor doesn't validate it. Either say "the adapter must truncate/enforce before calling" or drop the sentence — as written it reads like a checked precondition. **N6. `NTBSResponse.text` ownership for non-answer types** — `adapter.ts:14-17` -The processor bakes fixed English copy for `failure`/`timeout`/`cancellation` (`processor.ts:393-395`, `404`, `413`, `548-553`). If the intent is that adapters may re-render per platform, say on the type that `type` is the contract and `text` a default the adapter may replace; otherwise every platform ships the processor's prose. +The processor bakes fixed English copy for `failure`/`timeout`/`cancellation` (`processor.ts:388-403`, `539`). If the intent is that adapters may re-render per platform, say on the type that `type` is the contract and `text` a default the adapter may replace; otherwise every platform ships the processor's prose. + +**N7. Error-message style drifts** -**N7. Error-message style drifts** — e.g. "Problems getting the thread from the projection" (`processor.ts:672`) vs. the "Failed …" convention everywhere else. Cheap consistency win when touching those lines. +- "Problems getting the thread from the projection" (`processor.ts:653`) vs. the "Failed …" convention everywhere else. +- "Failed to retrieve turn for user message" (`processor.ts:632`) dropped the `${userMessageId}` interpolation its `resolveT3Outcome` twin kept (`processor.ts:362`) — align the wording and put the ID back in the message. -**N8. Spell out the acronym once** — none of the three source files says what NTBS stands for; the architecture block at `processor.ts:28` is where "Non-Turn-Based Surfaces" belongs. +**N8. Spell out the acronym once** — none of the three source files says what NTBS stands for; the architecture block at `processor.ts:28-29` is where "Non-Turn-Based Surfaces" belongs. --- ## 2. Bugs, edge cases, race conditions -**B1. A turn that never materializes leaves the request permanently unanswered (until restart)** — `processor.ts:360-365`, `653-658` -Mechanism, confirmed against the projection code: `thread.turn.start` emits `thread.message-sent` + `thread.turn-start-requested` (decider `planTurnStartEvents`), which writes the pending-start row carrying `pendingMessageId`. If the provider session settles before adopting that row — provider spawn failure, bad model config, runtime error before `turn.started` — the projection **deletes the pending row** (`ProjectionPipeline.ts:1347-1358`, "any settled status abandons an unadopted pending turn start") and no concrete turn row ever exists. From then on both turn lookups find **0** matching turns and hard-error: +**B1. A turn that never materializes leaves the request permanently unanswered (until restart)** — `processor.ts:360-365`, `630-635` +Mechanism, confirmed against the projection code: `thread.turn.start` emits `thread.message-sent` + `thread.turn-start-requested` (decider `planTurnStartEvents`), which writes the pending-start row carrying `pendingMessageId`. If the provider session settles before adopting that row — provider spawn failure, bad model config, runtime error before `turn.started` — the projection **deletes the pending row** (`ProjectionPipeline.ts:1347-1358`, "any settled status abandons an unadopted pending turn start") and no concrete turn row ever exists. From then on `getTurn` finds no turn and both callers hard-error: -- the `thread.session-set(error)` that reports the failure reaches `processT3Event`, which calls `resolveT3Outcome`, gets the "Expected exactly one turn, found 0" error, logs a warning, and moves on — the failure outcome is never posted; +- the `thread.session-set(error)` that reports the failure reaches `processT3Event`, which calls `resolveT3Outcome`, gets the "not found" error, logs a warning, and moves on — the failure outcome is never posted; - the monitor's `loadMessageStatus` fails the same way, exhausts its 3 retries, and the monitor dies. -Net: the platform user gets the acknowledgement and then silence, until a server restart lets `recoverThread`'s found-0 branch restart the turn. The same hole opens if a user deletes the NTBS thread from the T3 UI mid-run (`deleteByThreadId` removes all turn rows). +Net: the platform user gets the acknowledgement and then silence, until a server restart lets `recoverThread`'s no-turn branch restart the turn (`processor.ts:892-898`). The same hole opens if a user deletes the NTBS thread from the T3 UI mid-run (`deleteByThreadId` removes all turn rows). -Fix direction, in the single helper from S2: treat found-0 as a state, not a violation. Load the thread; session `null`/`starting`/`running` → still pending (return null / `stats: null`); session settled → terminal failure outcome ("T3 could not start work on this request", with `session.lastError`); thread gone from the projection → cancellation. Posting a response flips the record to `thread.response.posted`, so restart-recovery correctly won't retry it. The live path should _post failure_, not restart the turn — restarting on a spawn failure would loop; the bounded once-per-boot retry in recovery is the right place for retries to live. +Fix direction, at the call sites of `getTurn` (`processor.ts:333`): treat a `null` turn as a state, not a violation. Load the thread; session `null`/`starting`/`running` → still pending (return `null` / the pending status); session settled → terminal failure outcome ("T3 could not start work on this request", with `session.lastError`); thread gone from the projection → cancellation. Posting a response flips the record to `thread.response.posted`, so restart-recovery correctly won't retry it. The live path should _post failure_, not restart the turn — restarting on a spawn failure would loop; the bounded once-per-boot retry in recovery is the right place for retries to live. -**B2. Startup race: a request processed while recovery loads gets two monitors** — `processor.ts:1092-1115`, `912-981` -`loadThreadsAwaitingResponse` snapshots all `thread.created` records after the subscription starts. Any request that `process` handled before that load — record saved, monitor forked, still running — is also in the recovery list, so `recoverThread` sets a fresh baseline into the shared `messageStatus` entry and forks a **second** monitor for the same message. Consequences are contained (the outcome lock and state checks prevent double posting) but real: duplicated polling, and when the turn finishes, whichever monitor deletes the map entry first makes the other's next `checkProgress` fail with "No monitoring state exists" → 3 futile retries → a logged-error death that looks like a genuine failure. +**B2. Startup race: a request processed while recovery loads gets two monitors** — `processor.ts:1042-1073`, `883-938` +`start` forks the event consumer, then runs `recoverStoredThreads`, which snapshots all `thread.created` records. Any request that `process` handled before that load — record saved, monitor forked (`processor.ts:1003-1015`), still running — is also in the recovery list, so `recoverThread` forks a **second** monitor for the same message (`processor.ts:929-938`). Consequences are contained but real: duplicated polling, and both monitors can independently trip the stall path, so the interrupt + timeout flow can run twice (the outcome lock still prevents double posting). -There's also a narrower cousin: `recoverThread`'s found-0 branch can re-dispatch `thread.turn.start` if the recovery load lands in the small window between `adapter.save` and `startT3Turn` inside `process` (`processor.ts:1039-1044`). Same messageId, so the projection largely coalesces it, but it's the same root cause. +There's also a narrower cousin: `recoverThread`'s no-turn branch can re-dispatch `thread.turn.start` if the recovery load lands in the small window between `adapter.save` and `startT3Turn` inside `process` (`processor.ts:996-1001`). Same messageId, so the projection largely coalesces it, but it's the same root cause. -Fix: guard recovery internally — skip records whose `sourceUri` is in `inFlightRequests` (covers the save→ack span) or whose message is in the active-monitor `Set` from S1 (covers the rest of the turn's lifetime). Note that "wire startup so recovery finishes before webhooks go live" is _not_ currently expressible: `subscribeToT3Events` never returns, and nothing signals recovery completion. The internal guard avoids inventing that signal. +Fix: guard recovery internally — skip records whose `sourceUri` is in `inFlightRequests` (covers the save→ack span), and track actively monitored messages in a small `Set` so recovery skips those too (covers the rest of the turn's lifetime). Note that "wire startup so recovery finishes before webhooks go live" is _not_ currently expressible: `start` never returns, and nothing signals recovery completion. The internal guard avoids inventing that signal. -**B3. The 3-minute no-progress timeout will kill healthy turns** — `processor.ts:734-736` +**B3. The 3-minute no-progress timeout will kill healthy turns** — `processor.ts:698-700` `12 × 15s` of no _projected_ progress interrupts the turn. `getTurnStats`'s own doc comment concedes the limitation: buffered output, hidden reasoning, and provider work that produces no projected event are invisible. The concrete everyday case: a single long tool execution — an install, build, or test suite taking >3 minutes — projects an activity when the call starts and then nothing until it returns, with no assistant text streaming in between. The monitor will interrupt mid-build and post a timeout for a turn that was fine. The `TODO: config` is already there; beyond making it configurable, the default needs to be sized for agent work (10+ minutes), because there is no cheap signal that distinguishes "provider hung" from "tool call still running" at this altitude. -**B4. Monitor death is permanent and quiet** — `processor.ts:743-754` +**B4. Monitor death is permanent and quiet** — `processor.ts:712-723` A transient projection failure lasting ~45s (4 attempts, 15s apart) kills the monitor for that request; only a log line records it. Outcome posting still works via the event path, so the visible loss is just stall protection — but that's precisely the protection you can't tell is missing. Consider retrying the _load_ indefinitely with backoff and reserving monitor death for the genuinely-inconsistent-state errors. Low urgency, cheap to do while implementing B1 (which removes the most common source of these deaths). -**B5. Crash window between thread creation and `adapter.save` orphans a thread** — `processor.ts:1025-1041` +**B5. Crash window between thread creation and `adapter.save` orphans a thread** — `processor.ts:982-998` The architecture doc's original lifecycle persisted an `accepted` state _before_ touching T3; the simplified lifecycle (deliberately, and I agree with the cut) saves only after `createT3Thread` succeeds. Cost: a crash in that window leaves a T3 thread + worktree with no adapter record, and the redelivery creates a second thread. That's acceptable at-least-once behavior — but it contradicts the plan doc's "persist the accepted lifecycle state before starting T3 work", so record the decision in the `processor.ts` header comment (or the plan doc) so it reads as chosen, not missed. -**B6. Recovery assumes the turn projection is caught up at startup** — `processor.ts:917-931` -If projections hydrate asynchronously relative to when wiring calls `subscribeToT3Events`, `recoverThread` can read a stale 0-turn view and re-dispatch `thread.turn.start`. Reusing the recorded `userMessageId` makes this nearly idempotent, but two adopted turns for one message would trip every "exactly one" check and wedge the request. Since the wiring doesn't exist yet, this is a one-line requirement to write down wherever the processor gets started: projection catch-up happens-before `subscribeToT3Events`. +**B6. Recovery assumes the turn projection is caught up at startup** — `processor.ts:889-898` +If projections hydrate asynchronously relative to when wiring calls `start`, `recoverThread` can read a stale no-turn view and re-dispatch `thread.turn.start`. Reusing the recorded `userMessageId` makes this nearly idempotent, but if both dispatches produce adopted turns, `getTurn`'s first-match `find` silently tracks one while the duplicate runs the same work unmonitored in the same thread and worktree. Since the wiring doesn't exist yet, this is a one-line requirement to write down wherever the processor gets started: projection catch-up happens-before `start`. -**B7. Head-of-line blocking on the event loop** — `processor.ts:1079-1090` +**B7. Head-of-line blocking on the event loop** — `processor.ts:1029-1040` `Stream.runForEach` processes session-set events sequentially, and `processT3Event` holds the event loop through adapter lookups and — under the outcome lock — platform API calls. One slow Discord/Jira call delays outcome posting for every other request on the same adapter (adapters are isolated from each other; each has its own processor). Fine for v1 volumes; worth a comment so the serialization is visibly a choice, and the fix (fork per event once it matters) is understood. -Related, no action needed: `postResponse` posting successfully and then failing to `save` leaves the record `thread.created` with no further session-set to retry it in-lifetime; restart-recovery resolves it via `findMatchingResponseMessage` without re-posting. That's the designed net and it holds. +**B8. Failed final-response delivery waits for another event or restart** +The T3 event stream does not replay events. If handling a terminal `thread.session-set` fails, the processor logs the error and moves on; it tries again only after another session event or startup recovery. `monitorT3Turn` does not help because it exits when it sees a terminal turn. + +If this matters in practice, add a small bounded retry around terminal-event handling. `findMatchingResponseMessage` already prevents duplicate responses when posting succeeds but saving `thread.response.posted` fails. --- ## Reviewed and deliberately not flagged - `ensureUniqueOutcome` + per-message semaphore + `markResponsePosted` cleanup: correct, including the re-created-lock guard on delete. -- Subscription-before-recovery ordering in `subscribeToT3Events`: right pattern for a hot stream; no missed-event window. +- Subscription-before-recovery ordering in `start`: right pattern for a hot stream; no missed-event window. - `resolveWorktreeBase`: fetch-failure and unresolvable-ref fallbacks are sensible, and "never fails, let worktree creation carry the real git error" is the right call. - Worktree cleanup on `thread.create` failure, including the documented accepted leak of the temporary branch ref. - `findMatchingResponseMessage` consulted on every post: cheap, and it's the idempotency net for the post-then-crash window — keep it in the common path. From e67dc48a7373e114d7d82e36ccd7b1a1d044d3f8 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 15 Aug 2026 16:04:50 +0200 Subject: [PATCH 072/110] docs: scope pull request handoff rules --- AGENTS.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8d5bc1b6849e..fbe3060b157e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,9 +167,14 @@ whole branching model. See work dir (`~/.t3/compose-work`, not tmpfs `/tmp`) before install. See [docs/fork-stack.md](./docs/fork-stack.md) ("Integration overlay compose and lockfiles"). -## Pull requests (required handoff) +## Pull requests (when publishing) -When implementation work for a user request is done (code, docs, config — not pure Q&A): +Do not commit, rebase, push, or open/update a PR merely because an edit is complete. Do those +things only when the user explicitly requests publication or the specific version-control action, +or when another workflow in this file explicitly requires it (for example, Discord-originated +work). A request to change code, docs, or config does not by itself authorize publication. + +When publication or a PR handoff is in scope: 1. **Commit** the changes on a feature branch cut from `fork/dev`. 2. **Open or update a PR against `fork/dev`** before handing off — for every kind of work, including From fa9833b5cf618a97f32520214fd85da15f46c1a9 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 15 Aug 2026 16:26:04 +0200 Subject: [PATCH 073/110] chore: remove all the monitoring crap --- apps/server/src/ntbs/adapter.ts | 2 +- apps/server/src/ntbs/processor.ts | 350 ++---------------------- apps/server/src/ntbs/processor2.test.ts | 4 +- 3 files changed, 26 insertions(+), 330 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 39c9c16b4396..95225774c6bf 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -12,7 +12,7 @@ export class AdapterError extends Data.TaggedError("AdapterError")<{ }> {} export type NTBSResponse = { - readonly type: "answer" | "failure" | "timeout" | "cancellation"; + readonly type: "answer" | "failure" | "cancellation"; readonly text: string; }; diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 35d2238223f2..bc998f10ce08 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -2,19 +2,14 @@ import { type ChatAttachment, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, - type EventId, MessageId, OrchestrationCommand, type OrchestrationEvent, - type OrchestrationLatestTurn, - type OrchestrationLatestTurnState, - type OrchestrationThread, type ProjectId, ThreadId, - type TurnId, } from "@t3tools/contracts"; import type * as NTBS from "./lifecycle.ts"; -import { Context, Crypto, Data, DateTime, Effect, Schedule, Stream, Semaphore } from "effect"; +import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -33,7 +28,6 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; - Creates a fresh worktree and T3 thread. - Saves `ThreadCreated`. - Starts the first turn with the snapshot and attachments. - - Monitors the turn for completion and timeouts. - Attempts to post the acknowledgement independently. - Watches T3 events for completed work. - Posts the final result through the adapter and saves `ResponsePosted`. @@ -91,13 +85,12 @@ export interface NTBSProcessor { * The main loop of the processor, consumes T3 events and passes them to `processT3Event`. * * After the live subscription begins, loads stored `ThreadCreated` records. - * It starts a missing first turn, resumes monitoring an active turn, or posts - * the outcome of a turn that already finished. + * It starts a missing first turn, or posts the outcome of a turn that already finished. * * Runs until interrupted by its caller. * Logs individual processing failures and continues with later events. */ - readonly start: Effect.Effect; + readonly run: Effect.Effect; } export const makeNTBSProcessorTag = (key: string) => Context.Service(key); @@ -129,65 +122,6 @@ type NTBSProcessorRequirements = */ | Crypto.Crypto; -type TurnStats = { - readonly activityCount: number; - readonly latestActivityId: EventId | null; - readonly assistantTextLength: number; - readonly assistantUpdatedAt: string | null; -}; - -/** - * Gets the statistics visible in T3's projected activities and assistant - * messages for one turn. It includes recorded tool activity and assistant text - * that has reached the projection, but not necessarily buffered output, - * hidden reasoning, or provider work that produces no projected event. - * Comparing two results can indicate observable progress, but an unchanged - * result does not prove that the turn is stalled. - */ -const getTurnStats = ( - thread: OrchestrationThread, - turn: Pick, -): TurnStats => { - const activities = thread.activities.filter((activity) => activity.turnId === turn.turnId); - const assistantMessages = thread.messages.filter( - (message) => message.turnId === turn.turnId && message.role === "assistant", - ); - - const assistantUpdatedAt = assistantMessages.reduce( - (latest, message) => - latest === null || message.updatedAt > latest ? message.updatedAt : latest, - null, - ); - - return { - activityCount: activities.length, - latestActivityId: activities.at(-1)?.id ?? null, - assistantTextLength: assistantMessages.reduce( - (length, message) => length + message.text.length, - 0, - ), - assistantUpdatedAt, - }; -}; - -const hasProgress = (previous: TurnStats, current: TurnStats): boolean => - previous.activityCount !== current.activityCount || - previous.latestActivityId !== current.latestActivityId || - previous.assistantTextLength !== current.assistantTextLength || - previous.assistantUpdatedAt !== current.assistantUpdatedAt; - -type TurnStatus = - | { - readonly threadId: ThreadId; - state: "pending"; - } - | { - readonly threadId: ThreadId; - readonly turnId: TurnId; - readonly state: OrchestrationLatestTurnState; - readonly stats: TurnStats; - }; - /** * Creates an NTBS processor for one adapter. * @@ -306,30 +240,6 @@ export const makeNTBSProcessor = ( .pipe(orFail("Failed to start the first T3 turn")); }); - /** - * Requests interruption of one exact T3 turn. - */ - const interruptT3Turn = ( - threadId: ThreadId, - turnId: TurnId, - ): Effect.Effect => - Effect.gen(function* () { - const commandId = CommandId.make(yield* randomUUID); - const createdAt = yield* getNow; - - yield* orchestrationEngineService - .dispatch( - OrchestrationCommand.make({ - type: "thread.turn.interrupt", - commandId, - threadId, - turnId, - createdAt, - }), - ) - .pipe(orFail(`Failed to interrupt T3 turn ${turnId}`)); - }); - const getTurn = (threadId: ThreadId, userMessageId: MessageId) => Effect.gen(function* () { const turns = yield* projectionTurnRepository @@ -499,54 +409,6 @@ export const makeNTBSProcessor = ( ); }); - /** - * Stops a stalled T3 turn and reports the timeout to the external platform. - * - * Response handling is locked by user message so a normal completion and - * timeout cannot both post an outcome. - */ - const handleStalledTurn = ( - userMessageId: MessageId, - turn: TurnStatus, - ): Effect.Effect => - ensureUniqueOutcome( - userMessageId, - Effect.gen(function* () { - const lifecycle = yield* adapter - .findByThreadId(turn.threadId) - .pipe(orFail("Failed loading the NTBS lifecycle for a stalled turn")); - - if (lifecycle.state === "thread.response.posted") { - markResponsePosted(userMessageId); - return; - } - - if (turn.state !== "pending") { - const turnId = turn.turnId; - yield* interruptT3Turn(turn.threadId, turnId).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed interrupting stalled T3 turn", { - userMessageId, - threadId: turn.threadId, - turnId, - cause, - }), - ), - ); - } - - const response: NTBSResponse = { - type: "timeout", - text: - turn.state === "pending" - ? "T3 could not start this request after repeated checks." - : "T3 stopped this request after repeated checks found no observable progress.", - }; - - yield* postResponse(lifecycle, response); - }), - ); - /** * Resolves where a new thread worktree starts from. * @@ -617,136 +479,6 @@ export const makeNTBSProcessor = ( */ const inFlightRequests = new Set(); - /** - * Fetches fresh information for the turn created by one T3 user message. - */ - const loadMessageStatus = ( - userMessageId: MessageId, - threadId: ThreadId, - ): Effect.Effect => - Effect.gen(function* () { - const turn = yield* getTurn(threadId, userMessageId); - - if (!turn) { - return yield* new NTBSProcessorError({ - reason: `Failed to retrieve turn for user message`, - cause: { userMessageId, threadId }, - }); - } - - if (turn.turnId === null && turn.state === "pending") { - return { threadId, state: "pending" }; - } - - // NOTE: This is not a business-logic related check. - // Turn state *in practice* has always turnId === null and state === pending - // But since they live on different properties we need to make it typecheck and cross check the wire - if (turn.turnId === null || turn.state === "pending") { - return yield* new NTBSProcessorError({ - reason: `T3 turn state is inconsistent for user message ${userMessageId}.`, - cause: turn, - }); - } - - const maybeThread = yield* projectionSnapshotQuery - .getThreadDetailById(threadId) - .pipe(orFail("Problems getting the thread from the projection")); - - const thread = yield* Effect.fromOption(maybeThread).pipe( - orFail(`Could not load T3 thread ${threadId}`), - ); - - const stats = getTurnStats(thread, { - turnId: turn.turnId, - state: turn.state, - }); - return { threadId, turnId: turn.turnId, stats, state: turn.state }; - }); - - /** - * Loads the current turn status and compares it with the previous observation. - * The status recorded when monitoring begins is the initial baseline. - */ - const checkProgress = ( - userMessageId: MessageId, - previousStatus: TurnStatus, - ): Effect.Effect< - { readonly status: TurnStatus; readonly progressed: boolean }, - NTBSProcessorError - > => - Effect.gen(function* () { - const fresh = yield* loadMessageStatus(userMessageId, previousStatus.threadId); - - let progressed: boolean; - - if (previousStatus.state === "pending" && fresh.state === "pending") { - progressed = false; - } else if (previousStatus.state === "pending") { - progressed = true; - } else if (fresh.state === "pending") { - return yield* new NTBSProcessorError({ - reason: `T3 thread ${previousStatus.threadId} became pending after its turn had started.`, - cause: { previousStatus, fresh }, - }); - } else { - progressed = hasProgress(previousStatus.stats, fresh.stats); - } - - return { status: fresh, progressed }; - }); - - // TODO: These guys should come from some config - const CHECK_INTERVAL = "15 seconds"; - const MAX_NO_PROGRESS_CHECKS = 12; - - const monitorT3Turn = ( - userMessageId: MessageId, - initialStatus: TurnStatus, - ): Effect.Effect => - Effect.gen(function* () { - let consecutiveNoProgressChecks = 0; - - let previousStatus = initialStatus; - - while (true) { - const result = yield* checkProgress(userMessageId, previousStatus).pipe( - Effect.retry({ - times: 3, - schedule: Schedule.spaced(CHECK_INTERVAL), - }), - Effect.tapError((cause) => - Effect.logWarning("Failed checking T3 turn progress after retries", { - userMessageId, - cause, - }), - ), - ); - - previousStatus = result.status; - - const { status } = result; - - if (status.state !== "pending" && status.state !== "running") { - // it has completed already - return; - } - - if (result.progressed) { - // reset the counter - consecutiveNoProgressChecks = 0; - } else { - consecutiveNoProgressChecks += 1; - } - - if (consecutiveNoProgressChecks >= MAX_NO_PROGRESS_CHECKS) { - yield* handleStalledTurn(userMessageId, result.status); - return; - } - - yield* Effect.sleep(CHECK_INTERVAL); - } - }); - /** * Creates an isolated worktree and a new T3 thread. * @@ -887,7 +619,6 @@ export const makeNTBSProcessor = ( const { threadId, userMessageId } = threadCreated.t3Data; const turn = yield* getTurn(threadId, userMessageId); - let initialStatus: TurnStatus; if (!turn) { yield* startT3Turn( @@ -896,46 +627,26 @@ export const makeNTBSProcessor = ( threadCreated.snapshot, threadCreated.attachments, ); - initialStatus = { - threadId, - state: "pending", - }; } else { - const status = yield* loadMessageStatus(userMessageId, threadId); - initialStatus = status; - if (status.state !== "pending" && status.state !== "running") { - yield* ensureUniqueOutcome( - userMessageId, - Effect.gen(function* () { - const currentRecord = yield* adapter - .findByThreadId(threadId) - .pipe(orFail("Failed reloading the NTBS lifecycle during recovery")); - - if (currentRecord.state === "thread.response.posted") { - markResponsePosted(userMessageId); - return; - } - - const response = yield* resolveT3Outcome(threadId, userMessageId); - if (response !== null) { - yield* postResponse(currentRecord, response); - } - }), - ); - return; - } - } + yield* ensureUniqueOutcome( + userMessageId, + Effect.gen(function* () { + const currentRecord = yield* adapter + .findByThreadId(threadId) + .pipe(orFail("Failed reloading the NTBS lifecycle during recovery")); + + if (currentRecord.state === "thread.response.posted") { + markResponsePosted(userMessageId); + return; + } - yield* monitorT3Turn(userMessageId, initialStatus).pipe( - Effect.catch((cause) => - Effect.logError("Recovered NTBS turn monitor failed", { - userMessageId, - threadId, - cause, + const response = yield* resolveT3Outcome(threadId, userMessageId); + if (response !== null) { + yield* postResponse(currentRecord, response); + } }), - ), - Effect.forkDetach, - ); + ); + } }); /* @@ -947,8 +658,7 @@ export const makeNTBSProcessor = ( 2. Create the worktree and T3 thread. 3. Generate the first user message ID and record it with ThreadCreated. 4. Start the first T3 turn with that message ID, the snapshot, and attachments. - 5. Start monitoring the turn in the background. - 6. Attempt to post the acknowledgement independently. + 5. Attempt to post the acknowledgement independently. */ const process = (request: NTBS.NTBSInput, t3Context: T3Context) => Effect.gen(function* () { @@ -1000,20 +710,6 @@ export const makeNTBSProcessor = ( // Start the first T3 turn with that message Id, the snapshot and attachments yield* startT3Turn(threadId, userMessageId, request.snapshot, request.attachments); - yield* monitorT3Turn(userMessageId, { - threadId, - state: "pending", - }).pipe( - Effect.catch((cause) => - Effect.logError("NTBS turn monitor failed", { - userMessageId, - threadId, - cause, - }), - ), - Effect.forkDetach, - ); - yield* adapter.acknowledge(threadCreated).pipe( Effect.catch((cause) => Effect.logWarning("Failed posting the NTBS acknowledgement", { @@ -1064,7 +760,7 @@ export const makeNTBSProcessor = ( ), ); - const start = Effect.scoped( + const run = Effect.scoped( Effect.gen(function* () { yield* consumeT3Events.pipe(Effect.forkScoped({ startImmediately: true })); yield* recoverStoredThreads; @@ -1074,6 +770,6 @@ export const makeNTBSProcessor = ( return { process, - start, + run, }; }); diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts index cd4230d35d83..81ea09709380 100644 --- a/apps/server/src/ntbs/processor2.test.ts +++ b/apps/server/src/ntbs/processor2.test.ts @@ -219,7 +219,7 @@ describe("NTBSProcessor (layer harness)", () => { const adapterState = yield* TestAdapterState; const processor = yield* TestProcessor; - yield* processor.start.pipe(Effect.forkChild({ startImmediately: true })); + yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); const threadId = ThreadId.make("unknown-thread"); yield* engine.publish(yield* sessionSetEvent(threadId)); @@ -267,7 +267,7 @@ describe("NTBSProcessor (layer harness)", () => { responseMessageId: "already-posted", }); - yield* processor.start.pipe(Effect.forkChild({ startImmediately: true })); + yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); yield* engine.publish(yield* sessionSetEvent(threadId)); // The processor loads the record twice: once to route the event and once From a70a0aaa69605402c97fd5ab4ce4230e0e275ffa Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 15 Aug 2026 16:30:54 +0200 Subject: [PATCH 074/110] chore: refactor --- apps/server/src/ntbs/processor.ts | 45 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index bc998f10ce08..0bd0f96676f3 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -609,8 +609,8 @@ export const makeNTBSProcessor = ( /** * Resumes one stored NTBS thread after the processor starts. * - * Starts the original turn when it is missing, resumes monitoring while it - * is active, or posts its outcome when it already finished. + * Starts the original turn when it is missing, leaves active turns to the + * live event listener, or posts the outcome when a turn already finished. */ const recoverThread = ( threadCreated: NTBS.ThreadCreated, @@ -627,26 +627,31 @@ export const makeNTBSProcessor = ( threadCreated.snapshot, threadCreated.attachments, ); - } else { - yield* ensureUniqueOutcome( - userMessageId, - Effect.gen(function* () { - const currentRecord = yield* adapter - .findByThreadId(threadId) - .pipe(orFail("Failed reloading the NTBS lifecycle during recovery")); - - if (currentRecord.state === "thread.response.posted") { - markResponsePosted(userMessageId); - return; - } + return; + } - const response = yield* resolveT3Outcome(threadId, userMessageId); - if (response !== null) { - yield* postResponse(currentRecord, response); - } - }), - ); + if (turn.state === "pending" || turn.state === "running") { + return; } + + yield* ensureUniqueOutcome( + userMessageId, + Effect.gen(function* () { + const currentRecord = yield* adapter + .findByThreadId(threadId) + .pipe(orFail("Failed reloading the NTBS lifecycle during recovery")); + + if (currentRecord.state === "thread.response.posted") { + markResponsePosted(userMessageId); + return; + } + + const response = yield* resolveT3Outcome(threadId, userMessageId); + if (response !== null) { + yield* postResponse(currentRecord, response); + } + }), + ); }); /* From eeeca8570bf5e79324780f11054590d27742ffbd Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 15 Aug 2026 16:32:42 +0200 Subject: [PATCH 075/110] chore: remove stale/wrong todo --- docs/planning/ntbs-todos.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index 4e8860696c46..af1b75af9390 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -1,17 +1,3 @@ -# Competing outcome-drivers - -Outcome drivers are APIs that handle the turn end - -In `processor.ts` right now, both `monitorT3Turn` and `processT3Event` are competing for the same turn projection. - -The first one has a poll-based mechanism. After a turn starts, the polling checks changes and attempts to detect terminal state. - -The second one listens for `thread.session-set` events emitted by the T3 orchestration engine. - -We should analyze this issue and decide which one to keep. - -The important simplification is not which one wins; it is that terminal outcome response has a single owner. - # Is the whole NTBS contract a state machine under disguise? `NTBSAdapter.save` is a generic write: "store this record, whatever it is". From 68ae88f243c6924989132acd5718bba4256c3a9a Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sat, 15 Aug 2026 16:40:27 +0200 Subject: [PATCH 076/110] docs(ntbs): refresh processor reviews --- docs/planning/ntbs-processor.cc-review.md | 107 ++++++++--------- docs/planning/ntbs-processor.cod-review.md | 131 ++++++++------------- 2 files changed, 102 insertions(+), 136 deletions(-) diff --git a/docs/planning/ntbs-processor.cc-review.md b/docs/planning/ntbs-processor.cc-review.md index ac6a649d52bc..480ff7ddc595 100644 --- a/docs/planning/ntbs-processor.cc-review.md +++ b/docs/planning/ntbs-processor.cc-review.md @@ -1,100 +1,93 @@ # NTBS directory review -**Status:** review notes (Claude Code, 2026-08-14; updated 2026-08-15 — addressed items removed, numbering gaps are fixes that already landed) -**Scope:** `apps/server/src/ntbs/` — `processor.ts`, `adapter.ts`, `lifecycle.ts`, `test-helpers.ts`, `processor.test.ts`, `processor2.test.ts` — checked against the projection pipeline, decider, provider runtime ingestion, and the planning docs. +**Status:** Review notes (Claude Code, 2026-08-14; reconciled with the monitor-free processor on 2026-08-15). Addressed findings have been removed, so numbering gaps are intentional. -Overall the shape is right: the processor/adapter boundary is clean, the outcome lock design is sound, and the recovery model (durable record + turn lookup + `findMatchingResponseMessage`) handles the crash windows it was designed for. The findings below are refinements, ordered by how much I'd want them fixed. +**Scope:** The six files in [`apps/server/src/ntbs`](../../apps/server/src/ntbs/) and the orchestration/projection behavior they directly depend on. + +The processor/adapter boundary is generally clean. Startup recovery and the live `thread.session-set` listener now have distinct roles: recovery starts a missing turn or immediately reconciles a terminal one, while active turns are left to the listener. The remaining findings are refinements, ordered by practical impact. --- -## 1. Simplifications +## 1. Simplifications, naming, and contracts ### API and business logic -**S6. Half of `test-helpers.ts` is dead code** — `test-helpers.ts:99-270` -`TestEngine`, `TestAdapterState`, `TestAdapter`, and both derived layers are unexported and unused (only `createGitLayerMock` and `createAdapterRequest` are imported, by `processor.test.ts`). `processor2.test.ts` contains its own — already divergent — copies of the same fakes. Pick one harness (the `processor2.test.ts` one is the better design: state services + `Layer.provideMerge`, and the `threadLookups` queue-as-synchronization trick is good), move it into `test-helpers.ts`, and delete the rest. Fold `processor.test.ts` into the same file while at it: its single test (`processor.test.ts:156-166`) asserts nothing — it passes if `process` doesn't die — and the `eventReceived` Deferred in its adapter is never awaited. The assertions it was meant to make are already enumerated in `docs/planning/processor-testing.md` steps 1–8; write them against the surviving harness. +**S6. Consolidate the test harnesses.** + +Most of [`test-helpers.ts`](../../apps/server/src/ntbs/test-helpers.ts#L99) is unexported and unused; only `createGitLayerMock` and `createAdapterRequest` are imported by [`processor.test.ts`](../../apps/server/src/ntbs/processor.test.ts#L17). Meanwhile, [`processor2.test.ts`](../../apps/server/src/ntbs/processor2.test.ts#L27) contains a separate, already-divergent harness. Its state-service design is the stronger base, including the `threadLookups` queue used for synchronization. + +Keep one harness, move any reusable pieces into `test-helpers.ts`, and merge the tests into one `processor.test.ts`. The current “happy case” in [`processor.test.ts`](../../apps/server/src/ntbs/processor.test.ts#L156-L165) has no assertion, and its `eventReceived` deferred is created but never observed ([`processor.test.ts:22–42`](../../apps/server/src/ntbs/processor.test.ts#L22-L42)). Also fix the worktree fake: it reports `input.refName` as the created branch instead of `input.newRefName` ([`test-helpers.ts:34–42`](../../apps/server/src/ntbs/test-helpers.ts#L34-L42)). -**S7. Small cuts** +**S7. Deduplicate the fixed runtime settings.** -- `runtimeMode: "full-access"` + `interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE` are hardcoded twice (`processor.ts:301-302`, `818-819`) — one module-level constant pair, which is also where a future per-request override would land. +`runtimeMode: "full-access"` and `interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE` are repeated in both turn and thread creation ([`processor.ts:223–240`](../../apps/server/src/ntbs/processor.ts#L223-L240), [`processor.ts:538–552`](../../apps/server/src/ntbs/processor.ts#L538-L552)). One module-level constant would state that policy once and provide the natural home for a future override. ### Naming and contracts -**N2. `ThreadEvent` is not an event** — `lifecycle.ts:39` -It's the stored record shape (input + T3 ids); the states are `ThreadCreated`/`ResponsePosted` and the union is already correctly named `NTBSLifecycle`. `ThreadRecord` (or `LifecycleBase`) says what it is. Same file: the fields are mutable while everything in `processor.ts` is `readonly` — make the contract types `readonly` too. +**N2. `ThreadEvent` is a stored record, not an event.** -**N3. Misleading or stale comments** +[`ThreadEvent`](../../apps/server/src/ntbs/lifecycle.ts#L39-L50) is the common stored shape for the two lifecycle states. `ThreadRecord` or `LifecycleBase` would say what it is. The contract fields are also mutable while the processor treats them as immutable; make `sourceUri`, `snapshot`, `attachments`, `t3Data`, its nested IDs, `state`, and `responseMessageId` `readonly` ([`lifecycle.ts`](../../apps/server/src/ntbs/lifecycle.ts#L3-L65)). -- `processor.ts:467-470`: "we're only interested in the last user message that appears in the adapter records" — it's the _original_ user message recorded for the request, and it's the only one the adapter knows. "Last" implies a selection that doesn't happen. -- `processor.ts:641-643`: the note on the inconsistency check says "Turn state _in practice_ has always turnId === null and state === pending" — as written it claims every turn is always unadopted, which is false. What it means: adoption sets `turnId` and leaves `"pending"` in one step, so `turnId === null ⇔ state === "pending"`; the mixed combos can't occur, and the check exists to narrow the type (and trip loudly on a corrupted projection). -- `adapter.ts:41`: `acknowledge` doc still says "Returns the platform's identifier for the posted message" — it returns `Effect` since the signature was simplified. -- `lifecycle.ts:32-35`: "The processor creates them from attachment data provided by the adapter" — the processor passes `attachments` through untouched. The adapter creates them. -- `adapter.ts:48`: typo "idenitifier". +**N3. Remove or correct stale comments.** -**N4. `adapter.save` doesn't say it's an upsert or name its key** — `adapter.ts:33-36` -The processor calls `save` twice per lifecycle (created, then posted) and expects the second write to replace the first. Both test adapters guessed "keyed by threadId". State it: "Upserts the record for this request; `sourceUri` (equivalently the T3 thread, they're 1:1) is the identity." +- The architecture block still refers to generic `NTBSInput

`, although the generic platform data was removed ([`processor.ts:35–39`](../../apps/server/src/ntbs/processor.ts#L35-L39)). +- The event path says it selects the “last user message,” but it uses the one original user-message ID stored for the request; no selection occurs ([`processor.ts:377–381`](../../apps/server/src/ntbs/processor.ts#L377-L381)). +- Two outcome-lock comments still refer to timeout handling, which no longer exists ([`processor.ts:179–182`](../../apps/server/src/ntbs/processor.ts#L179-L182), [`processor.ts:383–388`](../../apps/server/src/ntbs/processor.ts#L383-L388)). +- The recovery-test TODO still says recovery should “monitor” the turn, and the second harness still mentions monitor baselines ([`processor.test.ts:84–85`](../../apps/server/src/ntbs/processor.test.ts#L84-L85), [`processor2.test.ts:36–38`](../../apps/server/src/ntbs/processor2.test.ts#L36-L38)). +- `acknowledge` returns `Effect`, not a platform message identifier ([`adapter.ts:38–43`](../../apps/server/src/ntbs/adapter.ts#L38-L43)). +- The adapter, not the processor, creates the T3 attachment references passed through by the input ([`lifecycle.ts:32–36`](../../apps/server/src/ntbs/lifecycle.ts#L32-L36)). +- Fix “idenitifier” in the `postResponse` documentation ([`adapter.ts:44–54`](../../apps/server/src/ntbs/adapter.ts#L44-L54)). -**N5. `snapshot`'s 120k limit names no enforcer** — `lifecycle.ts:26-30` -The processor doesn't validate it. Either say "the adapter must truncate/enforce before calling" or drop the sentence — as written it reads like a checked precondition. +**N4. `adapter.save` does not state its upsert semantics or identity key.** -**N6. `NTBSResponse.text` ownership for non-answer types** — `adapter.ts:14-17` -The processor bakes fixed English copy for `failure`/`timeout`/`cancellation` (`processor.ts:388-403`, `539`). If the intent is that adapters may re-render per platform, say on the type that `type` is the contract and `text` a default the adapter may replace; otherwise every platform ships the processor's prose. +The processor writes `thread.created` and later replaces it with `thread.response.posted` ([`processor.ts:340–346`](../../apps/server/src/ntbs/processor.ts#L340-L346), [`processor.ts:702–713`](../../apps/server/src/ntbs/processor.ts#L702-L713)), but the adapter contract only says “stores a lifecycle state” ([`adapter.ts:32–36`](../../apps/server/src/ntbs/adapter.ts#L32-L36)). State explicitly that this is an upsert and identify its key. The tests currently assume records are keyed by `threadId`, while `sourceUri` is documented as the durable request identity. -**N7. Error-message style drifts** +**N5. The 120,000-character input limit names no enforcer.** -- "Problems getting the thread from the projection" (`processor.ts:653`) vs. the "Failed …" convention everywhere else. -- "Failed to retrieve turn for user message" (`processor.ts:632`) dropped the `${userMessageId}` interpolation its `resolveT3Outcome` twin kept (`processor.ts:362`) — align the wording and put the ID back in the message. +[`NTBSInput.snapshot`](../../apps/server/src/ntbs/lifecycle.ts#L26-L31) documents the limit, but the processor does not validate it. Say that adapters must enforce it before calling `process`, or make the contract executable as proposed in the other review. -**N8. Spell out the acronym once** — none of the three source files says what NTBS stands for; the architecture block at `processor.ts:28-29` is where "Non-Turn-Based Surfaces" belongs. +**N6. Clarify who owns non-answer response text.** ---- +The processor supplies fixed English text for empty completions, failures, and cancellations ([`processor.ts:289–315`](../../apps/server/src/ntbs/processor.ts#L289-L315)), while [`NTBSResponse`](../../apps/server/src/ntbs/adapter.ts#L14-L17) carries both the semantic type and rendered text. If adapters may localize or replace this copy, document `text` as a default; otherwise the current contract means every platform must post the processor's prose verbatim. + +**N8. Spell out NTBS once.** -## 2. Bugs, edge cases, race conditions +None of the three production files expands the acronym. The architecture heading is the natural place to write “Non-Turn-Based Surfaces” ([`processor.ts:23–26`](../../apps/server/src/ntbs/processor.ts#L23-L26)). -**B1. A turn that never materializes leaves the request permanently unanswered (until restart)** — `processor.ts:360-365`, `630-635` -Mechanism, confirmed against the projection code: `thread.turn.start` emits `thread.message-sent` + `thread.turn-start-requested` (decider `planTurnStartEvents`), which writes the pending-start row carrying `pendingMessageId`. If the provider session settles before adopting that row — provider spawn failure, bad model config, runtime error before `turn.started` — the projection **deletes the pending row** (`ProjectionPipeline.ts:1347-1358`, "any settled status abandons an unadopted pending turn start") and no concrete turn row ever exists. From then on `getTurn` finds no turn and both callers hard-error: +--- -- the `thread.session-set(error)` that reports the failure reaches `processT3Event`, which calls `resolveT3Outcome`, gets the "not found" error, logs a warning, and moves on — the failure outcome is never posted; -- the monitor's `loadMessageStatus` fails the same way, exhausts its 3 retries, and the monitor dies. +## 2. Bugs, edge cases, and race conditions -Net: the platform user gets the acknowledgement and then silence, until a server restart lets `recoverThread`'s no-turn branch restart the turn (`processor.ts:892-898`). The same hole opens if a user deletes the NTBS thread from the T3 UI mid-run (`deleteByThreadId` removes all turn rows). +**B1. A turn that never materializes leaves the request unanswered until restart.** -Fix direction, at the call sites of `getTurn` (`processor.ts:333`): treat a `null` turn as a state, not a violation. Load the thread; session `null`/`starting`/`running` → still pending (return `null` / the pending status); session settled → terminal failure outcome ("T3 could not start work on this request", with `session.lastError`); thread gone from the projection → cancellation. Posting a response flips the record to `thread.response.posted`, so restart-recovery correctly won't retry it. The live path should _post failure_, not restart the turn — restarting on a spawn failure would loop; the bounded once-per-boot retry in recovery is the right place for retries to live. +`thread.turn.start` first creates a pending projected row. If the provider session settles before adopting it, the projection deliberately deletes that row ([`ProjectionPipeline.ts:1389–1406`](../../apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L1389-L1406)). The terminal `thread.session-set` event still reaches the NTBS listener, but [`resolveT3Outcome`](../../apps/server/src/ntbs/processor.ts#L263-L275) treats the missing turn as an error; the event loop logs the failure and moves on ([`processor.ts:730–740`](../../apps/server/src/ntbs/processor.ts#L730-L740)). No final response is posted. -**B2. Startup race: a request processed while recovery loads gets two monitors** — `processor.ts:1042-1073`, `883-938` -`start` forks the event consumer, then runs `recoverStoredThreads`, which snapshots all `thread.created` records. Any request that `process` handled before that load — record saved, monitor forked (`processor.ts:1003-1015`), still running — is also in the recovery list, so `recoverThread` forks a **second** monitor for the same message (`processor.ts:929-938`). Consequences are contained but real: duplicated polling, and both monitors can independently trip the stall path, so the interrupt + timeout flow can run twice (the outcome lock still prevents double posting). +Startup recovery eventually sees the missing turn and starts it again ([`processor.ts:615–630`](../../apps/server/src/ntbs/processor.ts#L615-L630)), but that makes a restart the only recovery path and may repeat a deterministic provider-start failure. Treat a missing turn as a state: inspect the thread session, return “still pending” for `null`/`starting`/`running`, produce a failure for a settled session (using `lastError` when appropriate), and treat a missing thread as cancellation. Keep restart recovery as the bounded retry path rather than restarting from the live terminal-event path. -There's also a narrower cousin: `recoverThread`'s no-turn branch can re-dispatch `thread.turn.start` if the recovery load lands in the small window between `adapter.save` and `startT3Turn` inside `process` (`processor.ts:996-1001`). Same messageId, so the projection largely coalesces it, but it's the same root cause. +**B2. Startup recovery can race normal processing into two turn-start commands.** -Fix: guard recovery internally — skip records whose `sourceUri` is in `inFlightRequests` (covers the save→ack span), and track actively monitored messages in a small `Set` so recovery skips those too (covers the rest of the turn's lifetime). Note that "wire startup so recovery finishes before webhooks go live" is _not_ currently expressible: `start` never returns, and nothing signals recovery completion. The internal guard avoids inventing that signal. +`process` saves `ThreadCreated` immediately before starting the turn ([`processor.ts:687–716`](../../apps/server/src/ntbs/processor.ts#L687-L716)). If `run` loads that record during the small save-to-dispatch window, recovery also sees no turn and starts it ([`processor.ts:743–771`](../../apps/server/src/ntbs/processor.ts#L743-L771)). The decider queues a second start when the first has already established `pendingTurnStart` ([`decider.ts:1171–1209`](../../apps/server/src/orchestration/decider.ts#L1171-L1209)); it does not make two commands with different command IDs idempotent merely because their message ID matches. -**B3. The 3-minute no-progress timeout will kill healthy turns** — `processor.ts:698-700` -`12 × 15s` of no _projected_ progress interrupts the turn. `getTurnStats`'s own doc comment concedes the limitation: buffered output, hidden reasoning, and provider work that produces no projected event are invisible. The concrete everyday case: a single long tool execution — an install, build, or test suite taking >3 minutes — projects an activity when the call starts and then nothing until it returns, with no assistant text streaming in between. The monitor will interrupt mid-build and post a timeout for a turn that was fine. The `TODO: config` is already there; beyond making it configurable, the default needs to be sized for agent work (10+ minutes), because there is no cheap signal that distinguishes "provider hung" from "tool call still running" at this altitude. +The narrow fix is to have `recoverThread` skip records whose `sourceUri` is present in [`inFlightRequests`](../../apps/server/src/ntbs/processor.ts#L476-L480). That closes the duplicate-dispatch window without reintroducing monitor tracking; a failed normal turn-start remains the separate redelivery/reconciliation issue described in the other review. -**B4. Monitor death is permanent and quiet** — `processor.ts:712-723` -A transient projection failure lasting ~45s (4 attempts, 15s apart) kills the monitor for that request; only a log line records it. Outcome posting still works via the event path, so the visible loss is just stall protection — but that's precisely the protection you can't tell is missing. Consider retrying the _load_ indefinitely with backoff and reserving monitor death for the genuinely-inconsistent-state errors. Low urgency, cheap to do while implementing B1 (which removes the most common source of these deaths). +**B5. A crash between T3 thread creation and `adapter.save` orphans resources.** -**B5. Crash window between thread creation and `adapter.save` orphans a thread** — `processor.ts:982-998` -The architecture doc's original lifecycle persisted an `accepted` state _before_ touching T3; the simplified lifecycle (deliberately, and I agree with the cut) saves only after `createT3Thread` succeeds. Cost: a crash in that window leaves a T3 thread + worktree with no adapter record, and the redelivery creates a second thread. That's acceptable at-least-once behavior — but it contradicts the plan doc's "persist the accepted lifecycle state before starting T3 work", so record the decision in the `processor.ts` header comment (or the plan doc) so it reads as chosen, not missed. +[`createT3Thread`](../../apps/server/src/ntbs/processor.ts#L493-L607) creates the worktree, dispatches `thread.create`, and runs setup before the durable NTBS record is written ([`processor.ts:697–713`](../../apps/server/src/ntbs/processor.ts#L697-L713)). A process exit after successful thread creation but before `save` leaves a thread/worktree that redelivery cannot discover, so redelivery creates another. This may be acceptable at-least-once behavior for the first version, but it should be recorded explicitly as a chosen crash window. -**B6. Recovery assumes the turn projection is caught up at startup** — `processor.ts:889-898` -If projections hydrate asynchronously relative to when wiring calls `start`, `recoverThread` can read a stale no-turn view and re-dispatch `thread.turn.start`. Reusing the recorded `userMessageId` makes this nearly idempotent, but if both dispatches produce adopted turns, `getTurn`'s first-match `find` silently tracks one while the duplicate runs the same work unmonitored in the same thread and worktree. Since the wiring doesn't exist yet, this is a one-line requirement to write down wherever the processor gets started: projection catch-up happens-before `start`. +**B7. Serial event handling creates head-of-line blocking.** -**B7. Head-of-line blocking on the event loop** — `processor.ts:1029-1040` -`Stream.runForEach` processes session-set events sequentially, and `processT3Event` holds the event loop through adapter lookups and — under the outcome lock — platform API calls. One slow Discord/Jira call delays outcome posting for every other request on the same adapter (adapters are isolated from each other; each has its own processor). Fine for v1 volumes; worth a comment so the serialization is visibly a choice, and the fix (fork per event once it matters) is understood. +[`Stream.runForEach`](../../apps/server/src/ntbs/processor.ts#L730-L741) handles session events sequentially, and one event can perform adapter reads plus a remote response post before the next event is consumed ([`processor.ts:355–410`](../../apps/server/src/ntbs/processor.ts#L355-L410)). One slow Discord/Jira call therefore delays all other outcomes for the same adapter. This is reasonable for initial volumes; add a comment that serialization is intentional, then introduce bounded per-event concurrency only if measurements justify it. -**B8. Failed final-response delivery waits for another event or restart** -The T3 event stream does not replay events. If handling a terminal `thread.session-set` fails, the processor logs the error and moves on; it tries again only after another session event or startup recovery. `monitorT3Turn` does not help because it exits when it sees a terminal turn. +**B8. Failed final-response delivery waits for another event or restart.** -If this matters in practice, add a small bounded retry around terminal-event handling. `findMatchingResponseMessage` already prevents duplicate responses when posting succeeds but saving `thread.response.posted` fails. +If terminal-event handling fails while searching for, posting, or recording the response, the event consumer logs the error and continues ([`processor.ts:325–349`](../../apps/server/src/ntbs/processor.ts#L325-L349), [`processor.ts:730–740`](../../apps/server/src/ntbs/processor.ts#L730-L740)). The T3 event stream does not replay that event, so another relevant session event or startup recovery is required before the processor retries. A small bounded retry around terminal-event reconciliation would close this gap. `findMatchingResponseMessage` already protects the post-succeeded/save-failed retry window from an ordinary duplicate ([`processor.ts:330–346`](../../apps/server/src/ntbs/processor.ts#L330-L346)). --- ## Reviewed and deliberately not flagged -- `ensureUniqueOutcome` + per-message semaphore + `markResponsePosted` cleanup: correct, including the re-created-lock guard on delete. -- Subscription-before-recovery ordering in `start`: right pattern for a hot stream; no missed-event window. -- `resolveWorktreeBase`: fetch-failure and unresolvable-ref fallbacks are sensible, and "never fails, let worktree creation carry the real git error" is the right call. -- Worktree cleanup on `thread.create` failure, including the documented accepted leak of the temporary branch ref. -- `findMatchingResponseMessage` consulted on every post: cheap, and it's the idempotency net for the post-then-crash window — keep it in the common path. -- Posting the timeout response before interrupt confirmation (a late real answer gets discarded): a defensible product trade-off, already serialized correctly. +- `ensureUniqueOutcome` and its per-message semaphore correctly serialize the startup-recovery/live-event race and clean up after the response is recorded ([`processor.ts:149–205`](../../apps/server/src/ntbs/processor.ts#L149-L205)). +- Subscribing before recovery is the right ordering for a hot event stream ([`processor.ts:768–773`](../../apps/server/src/ntbs/processor.ts#L768-L773)). +- `resolveWorktreeBase` has sensible fetch and ref-resolution fallbacks ([`processor.ts:412–470`](../../apps/server/src/ntbs/processor.ts#L412-L470)). +- Worktree cleanup on `thread.create` failure, including the documented temporary-branch leak, is deliberate ([`processor.ts:538–588`](../../apps/server/src/ntbs/processor.ts#L538-L588)). +- Consulting `findMatchingResponseMessage` on every response attempt is the idempotency net for the post-then-crash window and belongs in the common path ([`processor.ts:318–349`](../../apps/server/src/ntbs/processor.ts#L318-L349)). diff --git a/docs/planning/ntbs-processor.cod-review.md b/docs/planning/ntbs-processor.cod-review.md index 12dd98219979..528c3c172c21 100644 --- a/docs/planning/ntbs-processor.cod-review.md +++ b/docs/planning/ntbs-processor.cod-review.md @@ -1,151 +1,124 @@ # NTBS processor review -Scope: the six files currently under `apps/server/src/ntbs`, their direct code references, and the orchestration/persistence behavior on which the processor relies. I did not use the other planning documents as input. +**Status:** Reconciled with the monitor-free processor on 2026-08-15. Addressed findings have been removed, so numbering gaps are intentional. -The implementation has a sound core idea: one opaque external-request locator, one fresh T3 thread, an exact user-message ID for finding the corresponding turn, and a small two-state adapter record. The main remaining refinements are to make ownership singular and durability explicit. At present there are no production imports, adapter implementations, or runtime wiring outside `apps/server/src/ntbs`; only the NTBS tests reference these exports. That is fine for a branch still defining the component, but the current code is inert until an adapter and processor lifecycle are wired. +**Scope:** The six files in [`apps/server/src/ntbs`](../../apps/server/src/ntbs/), their direct code references, and the orchestration/persistence behavior on which the processor relies. Other planning documents were not used as input. -## 1. Simplifications, naming, and contracts - -### S1. Use one completion driver instead of polling and events - -The same turn is currently owned by two mechanisms: - -- `monitorT3Turn` polls the turn projection, detects terminal state, and then only returns ([processor.ts](../../apps/server/src/ntbs/processor.ts#L738)). -- `processT3Event` listens for `thread.session-set`, queries the same turn projection, and posts the result ([processor.ts](../../apps/server/src/ntbs/processor.ts#L455)). -- `responseLocks`, `messageStatus`, and the recovery choreography exist largely to keep those two paths from producing competing outcomes ([processor.ts](../../apps/server/src/ntbs/processor.ts#L223)). +The implementation has a sound core: one opaque external-request locator, one fresh T3 thread, an exact user-message ID for finding the corresponding turn, and a two-state adapter record. Completion now has one live owner—the `thread.session-set` event listener—while startup recovery only starts missing turns or reconciles outcomes that finished while the processor was down. The main remaining refinements are durability and making the small contracts say exactly what the processor assumes. -The smallest design is to let the monitor reconcile every observation: when it sees a terminal turn, resolve and post that outcome; when it reaches the timeout policy, interrupt and post the timeout. Startup recovery only needs to start a monitor for each pending record. This would remove `processT3Event`, `consumeT3Events`, the hot-stream dependency, and most or all of `responseLocks`. Final replies would be delayed by at most the polling interval, currently 15 seconds. +At present, no production code outside the NTBS directory constructs an adapter or processor, so the component remains inert until runtime wiring is added. -If near-immediate replies are a hard requirement, choose the opposite ownership model: make the event consumer the sole terminal-outcome driver and keep a timer only for timeouts. The important simplification is not which one wins; it is that terminal response delivery has one owner. +## 1. Simplifications, naming, and contracts ### S2. Replace generic storage operations with explicit state transitions -`NTBSAdapter.save` can write either lifecycle variant with no transition or uniqueness semantics. The processor separately calls `findByRequest`, creates resources, and later calls `save`. This is a broad API for a narrow state machine and leaves the important guarantees implicit. +[`NTBSAdapter.save`](../../apps/server/src/ntbs/adapter.ts#L32-L36) can write either lifecycle variant without stating transition, uniqueness, or upsert semantics. The processor separately checks [`findByRequest`](../../apps/server/src/ntbs/processor.ts#L687-L695), creates resources, and saves afterward. That broad API leaves the important guarantees implicit. -A plainer repository contract would expose intent rather than arbitrary persistence: +A plainer repository contract would expose intent: - `claimRequest(request)` atomically inserts the external request and reports whether this caller claimed it; - `attachThread(requestUri, threadId, userMessageId)` records the created T3 resources; -- `findByThreadId(threadId)` returns an option/null rather than a second absence convention (`ThreadNotFound`); -- `listPendingResponses()` replaces the effect-valued `loadThreadsAwaitingResponse` name; +- `findByThreadId(threadId)` returns `null` rather than introducing a second absence convention through `ThreadNotFound`; +- `listPendingResponses()` replaces `loadThreadsAwaitingResponse`; - `markResponded(threadId, responseMessageId)` is the only terminal transition. -This adds a small durable `claimed`/`provisioning` state, but removes `inFlightRequests` as a correctness mechanism, prevents backwards writes such as `ResponsePosted -> ThreadCreated`, and makes adapter conformance testable. The existing Jira delivery store already uses this shape: it claims a delivery before thread/worktree side effects. - -The outbound half should likewise be one adapter operation, for example `postResponseOnce(record, response)`, with a documented stable platform marker or idempotency key. The current `findMatchingResponseMessage` followed by `postResponse` makes the processor understand an adapter-specific recovery protocol, yet still cannot make the pair atomic. +This introduces a small durable claimed/provisioning state, but removes `inFlightRequests` as a correctness boundary, prevents backwards writes such as `ResponsePosted -> ThreadCreated`, and makes adapter conformance testable. The existing [`JiraDeliveryStore.claim`](../../apps/server/src/jira/JiraDeliveryStore.ts#L66-L66) is a nearby example of atomic admission before side effects. -### S3. Remove surface and data that carry no behavior +The outbound half could likewise be one adapter operation such as `postResponseOnce(record, response)`, with a documented stable platform marker or idempotency key. The current [`findMatchingResponseMessage` then `postResponse`](../../apps/server/src/ntbs/processor.ts#L325-L338) makes the processor understand an adapter recovery protocol without making that pair atomic. -These cuts are mechanical and do not change the design: +### S3. Remove the processor tag factory until it has a production consumer -- The exact-turn lookup and its “exactly one” error are duplicated in `resolveT3Outcome` and `loadMessageStatus` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L353), [processor.ts](../../apps/server/src/ntbs/processor.ts#L646)). Extract one `loadRequestTurn(threadId, userMessageId)` helper. -- `makeNTBSProcessorTag` has no consumer except the test harness. The factory already returns the service value, so the extra tag factory can wait until production wiring demonstrates a need. `makeNTBSAdapterTag` remains useful if several adapter-specific processor layers are actually constructed. - -`getTurnStats` should stay conservative for now. Some of its fields look correlated, but removing activity count, last activity ID, assistant length, or update time without first checking provider projection behavior would weaken stall detection for little benefit. +[`makeNTBSProcessorTag`](../../apps/server/src/ntbs/processor.ts#L96) is used only by the two test harnesses ([`processor.test.ts:45`](../../apps/server/src/ntbs/processor.test.ts#L45), [`processor2.test.ts:159`](../../apps/server/src/ntbs/processor2.test.ts#L159)). The factory already returns the processor service value, so the additional tag factory can wait until production wiring demonstrates a need. `makeNTBSAdapterTag` remains useful if multiple adapter-specific processor layers will be built. ### S4. Use record-oriented, plain names -The current types mix events, T3 lifecycle terms, and stored adapter state. They are records rather than domain events, and the processor assumes exactly one external request per fresh T3 thread even though “latest lifecycle state associated with a T3 thread” suggests otherwise. +The current names mix events, lifecycle language, and stored adapter state. These values are records, and the processor assumes one external request per fresh T3 thread. -| Current | Plainer option | Reason | -| --------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `NTBSLifecycle` | `NTBSRequestRecord` | It is the adapter's current stored record, not a lifecycle process. | -| `ThreadEvent` | `ThreadRequestRecord` or no standalone base alias | Nothing emits this value as an event. | -| `ThreadCreated` | `PendingResponse` | The processor primarily cares that this record still needs a response. | -| `ResponsePosted` | `RespondedRequest` | Names the terminal request state. | -| `t3Data` | `thread` | `record.thread.threadId` and `record.thread.userMessageId` state the contents directly. | -| `T3Context` | `ThreadTarget` | It is only the project and base ref used to create a thread. | -| `snapshot` | `prompt` or `capturedText` | The value is sent verbatim as the first user message; “snapshot” does not say of what. | -| `postAcknowledgement` | `acknowledge` | The operation is best-effort and its result is unused. | -| `subscribeToT3Events` | `run` (if it remains long-lived) or `recoverPending` (if polling owns completion) | The current operation also performs recovery and never returns. | +| Current | Plainer option | Reason | +| ------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------ | +| [`NTBSLifecycle`](../../apps/server/src/ntbs/lifecycle.ts#L65) | `NTBSRequestRecord` | It is the adapter's current stored record, not a process. | +| [`ThreadEvent`](../../apps/server/src/ntbs/lifecycle.ts#L39-L50) | `ThreadRequestRecord` or no base alias | Nothing emits it as an event. | +| [`ThreadCreated`](../../apps/server/src/ntbs/lifecycle.ts#L52-L58) | `PendingResponse` | The processor cares that this record still needs a response. | +| [`ResponsePosted`](../../apps/server/src/ntbs/lifecycle.ts#L60-L63) | `RespondedRequest` | Names the terminal request state. | +| [`t3Data`](../../apps/server/src/ntbs/lifecycle.ts#L40-L49) | `thread` | `record.thread.threadId` and `record.thread.userMessageId` state the contents directly. | +| [`T3Context`](../../apps/server/src/ntbs/processor.ts#L48-L62) | `ThreadTarget` | It contains only the project and base ref used to create a thread. | +| [`snapshot`](../../apps/server/src/ntbs/lifecycle.ts#L26-L31) | `prompt` or `capturedText` | The value is sent verbatim as the first user message; “snapshot” does not say what was captured. | -The recent removal of generic `PlatformData` is a good simplification and should not be reversed. Keeping one opaque, adapter-owned URI is easier to persist and recover. `sourceUri` could become `requestUri` to emphasize both identity and addressability, but that rename is optional; the more important change is to make it a validated, non-empty value. +The recent removal of generic platform data is a good simplification and should not be reversed. Keeping one opaque, adapter-owned URI is easier to persist and recover. `sourceUri` could become `requestUri` to emphasize identity and addressability, but that rename is optional; the more important change is to validate it as non-empty. ### S5. Make the input contract executable -`NTBSInput` is a plain TypeScript type whose strongest requirements exist only in comments. In particular, `sourceUri` may be empty, `snapshot` may be blank or exceed 120,000 characters, and the attachment array may exceed the provider limit of eight. The orchestration turn-start command accepts an unrestricted string/array; the tighter provider validation happens later, after a worktree, thread, and lifecycle record already exist. +[`NTBSInput`](../../apps/server/src/ntbs/lifecycle.ts#L3-L37) is a plain TypeScript type whose strongest requirements exist only in comments. `sourceUri` may be empty, `snapshot` may be blank or exceed 120,000 characters, and the attachment array may exceed the provider limit of eight. The orchestration command accepts the values, while tighter provider validation occurs later, after resources and a lifecycle record can already exist. -Define an Effect schema for the inbound boundary and reuse `PROVIDER_SEND_TURN_MAX_INPUT_CHARS`, `PROVIDER_SEND_TURN_MAX_ATTACHMENTS`, and `ChatAttachment`. Decode it before claiming or creating resources. This is both less prose to keep synchronized and a clearer contract for every future adapter. +Define an Effect schema for the inbound boundary and reuse [`PROVIDER_SEND_TURN_MAX_INPUT_CHARS` and `PROVIDER_SEND_TURN_MAX_ATTACHMENTS`](../../packages/contracts/src/orchestration.ts#L146-L147) together with [`ChatAttachment`](../../packages/contracts/src/orchestration.ts#L181-L182). Decode before claiming or creating resources. This reduces prose that can drift and gives every adapter one executable contract. ### S6. Consolidate the transitional test suite The directory currently carries two harnesses and two processor test files: -- `processor.test.ts` contains one “happy case” that only calls `process`; it has no assertions despite the preceding checklist ([processor.test.ts](../../apps/server/src/ntbs/processor.test.ts#L70), [processor.test.ts](../../apps/server/src/ntbs/processor.test.ts#L153)). -- `processor2.test.ts` is the more coherent layer harness and should become the sole `processor.test.ts`. -- Most of `test-helpers.ts` after `createAdapterRequest` is an unfinished second copy of the same harness and is not exported or used ([test-helpers.ts](../../apps/server/src/ntbs/test-helpers.ts#L75)). -- `createGitLayerMock` returns `input.refName` as the created worktree branch rather than `input.newRefName`, so a future command assertion would observe the base commit as the thread branch ([test-helpers.ts](../../apps/server/src/ntbs/test-helpers.ts#L34)). +- [`processor.test.ts`](../../apps/server/src/ntbs/processor.test.ts#L70-L85) describes a full happy path and missing-turn recovery, but its only test merely calls `process` without assertions ([`processor.test.ts:156–165`](../../apps/server/src/ntbs/processor.test.ts#L156-L165)). +- [`processor2.test.ts`](../../apps/server/src/ntbs/processor2.test.ts#L27-L171) is the more coherent layer harness and should become the sole `processor.test.ts`. +- Most of [`test-helpers.ts`](../../apps/server/src/ntbs/test-helpers.ts#L75) is an unfinished second copy of that harness and is not exported or used. +- [`createGitLayerMock`](../../apps/server/src/ntbs/test-helpers.ts#L34-L42) returns `input.refName` as the created worktree branch rather than `input.newRefName`, so a command assertion would observe the base commit as the thread branch. -Delete the no-assertion test and unused helper harness, rename `processor2.test.ts`, and grow that one harness around state transitions. The two focused test files currently pass (four tests total), but that result says little about the end-to-end lifecycle because only three tests make assertions and none completes a real request-to-response path. +Delete the no-assertion test and unused helper harness, rename `processor2.test.ts`, and grow that one harness around state transitions. The two files currently contain four tests, but only three assert behavior and none covers a complete request-to-response lifecycle. ## 2. Bugs, edge cases, and race conditions ### B1. Blocking before integration: no production code constructs or runs NTBS -No code outside `apps/server/src/ntbs` imports `makeNTBSProcessor`, `makeNTBSAdapterTag`, `NTBSProcessor`, or `subscribeToT3Events`. There is also no production adapter implementation. Consequently neither request processing nor startup recovery can currently execute. Treat this as integration status rather than an algorithm bug, but it is the first readiness item before assessing runtime behavior. +The public entry points are [`makeNTBSProcessor`](../../apps/server/src/ntbs/processor.ts#L125-L133), [`makeNTBSAdapterTag`](../../apps/server/src/ntbs/adapter.ts#L95), and [`NTBSProcessor.run`](../../apps/server/src/ntbs/processor.ts#L69-L94), but their only consumers are the NTBS tests. There is no production adapter implementation. Consequently neither request processing nor startup recovery can execute. Treat this as integration status rather than an algorithm bug, but it is the first readiness item. ### B2. High: inbound deduplication is a check-then-act race -`inFlightRequests` protects only one in-memory processor instance. After that local check, `findByRequest` and resource creation are separate effects ([processor.ts](../../apps/server/src/ntbs/processor.ts#L996)). Two server processes, two processor instances, or an overlapping restart can both observe no record and both create a worktree/thread for the same `sourceUri`. The adapter contract recommends a natural unique key but does not require an atomic insert or define conflict behavior. +[`inFlightRequests`](../../apps/server/src/ntbs/processor.ts#L476-L480) protects only one processor instance. After that local check, [`findByRequest`](../../apps/server/src/ntbs/processor.ts#L687-L695) and resource creation are separate effects. Two processes, two processor instances, or an overlapping restart can both observe no record and create a worktree/thread for the same `sourceUri`. The adapter recommends a natural unique key but does not require atomic insertion or define conflict behavior. -Use the atomic `claimRequest` transition described in S2 and enforce a unique key in adapter storage. The in-memory set may remain as a cheap duplicate suppressor, but it must not be the correctness boundary. +Use the atomic `claimRequest` transition from S2 and enforce a unique key in adapter storage. The in-memory set may remain as a cheap duplicate suppressor, but it should not be the correctness boundary. ### B3. High: the durable record is written after irreversible resources are created -`createT3Thread` creates a worktree, dispatches `thread.create`, and runs setup before `ThreadCreated` is saved ([processor.ts](../../apps/server/src/ntbs/processor.ts#L790), [processor.ts](../../apps/server/src/ntbs/processor.ts#L1024)). A process exit after `thread.create` commits, a process exit during setup, or an adapter `save` failure leaves a real T3 thread/worktree with no request record. The next delivery sees no record and creates another one. Cleanup only covers failure of `thread.create` itself; it cannot cover a successful dispatch followed by process loss. +[`createT3Thread`](../../apps/server/src/ntbs/processor.ts#L493-L607) creates a worktree, dispatches `thread.create`, and runs setup before [`ThreadCreated` is saved](../../apps/server/src/ntbs/processor.ts#L697-L713). A process exit after successful dispatch, during setup, or before `save` leaves a real T3 thread/worktree with no request record. Redelivery sees no record and creates another. -Claim and persist the request before provisioning. Record the generated thread/message IDs as soon as they are chosen, then make provisioning/recovery resume from that record. Deterministic IDs derived from the claim would be another option, but are not necessary if the state transition is durable. +Claim and persist the request before provisioning. Record generated thread/message IDs as soon as they are chosen, then make provisioning/recovery resume from that record. Deterministic IDs derived from the claim are another option, but are not required if the transition is durable. ### B4. High: a saved request can become dormant after turn-start failure -The processor saves `ThreadCreated` and then dispatches `thread.turn.start` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1039)). If turn start fails or the processing fiber is interrupted after the save, the record correctly remains pending. However, a redelivery finds any existing lifecycle state and immediately returns ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1017)). `recoverThread` can start a missing turn, but it runs only during `subscribeToT3Events` startup, not on redelivery. - -Make `process` mean “ensure this request is processing”: when `findByRequest` returns a pending record, call the same idempotent reconciliation used by startup recovery. Only a responded record should be an immediate no-op. This also collapses the split between normal processing and recovery. +The processor saves `ThreadCreated` and then dispatches `thread.turn.start` ([`processor.ts:702–716`](../../apps/server/src/ntbs/processor.ts#L702-L716)). If turn start fails or the processing fiber is interrupted after the save, the record remains pending. A redelivery finds any existing lifecycle state and immediately returns ([`processor.ts:687–695`](../../apps/server/src/ntbs/processor.ts#L687-L695)). Only startup recovery reconciles the record ([`processor.ts:609–655`](../../apps/server/src/ntbs/processor.ts#L609-L655)). -### B5. High: response delivery has both a retry gap and a cross-process duplicate race +Make `process` mean “ensure this request is processing”: when `findByRequest` returns a pending record, invoke the same idempotent reconciliation used at startup. Only a responded record should be an immediate no-op. -When `postResponse` or `findMatchingResponseMessage` fails, the event consumer logs the failure and continues ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1079)). The monitor independently sees that the turn is terminal and exits ([processor.ts](../../apps/server/src/ntbs/processor.ts#L756)). With no later `thread.session-set` event, nothing retries until the whole processor restarts. +### B5. High: response delivery has a retry gap and a cross-process duplicate race -Conversely, two processor instances can both run `findMatchingResponseMessage`, both receive `null`, and both post before either saves `ResponsePosted`. The user-message semaphore is process-local and does not prevent this. The existing recovery check helps only after one response is visible; it is not an atomic exactly-once guarantee. Its contract also does not say how an adapter distinguishes a final response from an acknowledgement when both relate to the same source URI. +When response lookup, posting, or persistence fails, the event consumer logs the failure and continues ([`processor.ts:325–349`](../../apps/server/src/ntbs/processor.ts#L325-L349), [`processor.ts:730–740`](../../apps/server/src/ntbs/processor.ts#L730-L740)). With no later `thread.session-set` event, nothing retries until processor restart. -Use a durable response-delivery claim/outbox plus a stable platform marker, or make `postResponseOnce` an explicitly idempotent adapter primitive. Retry pending delivery on a bounded schedule in the running process; startup recovery should be the fallback, not the normal retry mechanism. +Conversely, two processor instances can both call `findMatchingResponseMessage`, both receive `null`, and both post before either saves `ResponsePosted`. The user-message semaphore is process-local ([`processor.ts:149–205`](../../apps/server/src/ntbs/processor.ts#L149-L205)). The recovery lookup protects the post-succeeded/save-failed window only after one response is visible; it is not an atomic exactly-once guarantee, and the adapter contract does not explain how it distinguishes a final response from an acknowledgement ([`adapter.ts:66–75`](../../apps/server/src/ntbs/adapter.ts#L66-L75)). -### B6. High: timeout can claim work stopped when it is still running - -The comments correctly state that unchanged projected stats do not prove a stall ([processor.ts](../../apps/server/src/ntbs/processor.ts#L141)), but the implementation treats 12 unchanged 15-second polls—about three minutes—as a stall and interrupts the turn ([processor.ts](../../apps/server/src/ntbs/processor.ts#L734)). A coding turn can legitimately spend that long in provider work, a subprocess, or buffered output with no new projected activity. - -More importantly, interrupt failure is caught and ignored, after which a timeout response is posted anyway ([processor.ts](../../apps/server/src/ntbs/processor.ts#L534)). Even a successful dispatch only proves that the interrupt command was accepted, not that the provider stopped. The full-access agent may therefore continue modifying the worktree after the external platform is told that T3 “stopped this request.” - -Use a configurable elapsed-time SLA and treat observed progress only as a deadline extension, not proof that a short silence is a stall. After interrupt, wait for provider/session confirmation that the turn is no longer running before claiming it stopped. The turn projection alone is insufficient because `thread.turn-interrupt-requested` marks it interrupted when the request is recorded, before provider shutdown is confirmed. If confirmation cannot be obtained, use honest text such as “The response timed out; the T3 thread may still be running” and link or identify the thread rather than asserting cancellation. - -### B7. Medium: detached monitors have no processor-owned lifetime - -Both normal processing and recovery start monitors with `Effect.forkDetach` ([processor.ts](../../apps/server/src/ntbs/processor.ts#L971), [processor.ts](../../apps/server/src/ntbs/processor.ts#L1050)). Interrupting the scoped `subscribeToT3Events` effect stops the event subscription but not those monitors. Rebuilding the layer can leave old monitors using the same adapter while new recovery monitors start, and tests/runtime shutdown cannot reliably await their completion. - -Fork monitors in a processor-owned scope keyed by user message ID, and interrupt that scope when the processor stops. This also provides a direct place to prevent duplicate monitors without a second free-floating map. +Use a durable response-delivery claim/outbox or an explicitly idempotent `postResponseOnce` adapter primitive. Add a bounded in-process retry; startup recovery should be the fallback rather than the normal retry mechanism. ### B8. Medium: event processing is serial and includes remote adapter I/O -`Stream.runForEach` processes domain events one at a time, and `processT3Event` may perform adapter lookup, response search, response posting, and persistence before the next event is consumed ([processor.ts](../../apps/server/src/ntbs/processor.ts#L1079)). One slow or hung platform call therefore blocks outcomes for every other NTBS thread and lets the unbounded event PubSub backlog grow. +[`Stream.runForEach`](../../apps/server/src/ntbs/processor.ts#L730-L741) processes domain events one at a time. A relevant event may perform adapter lookup, response search, response posting, and persistence before the next event is consumed ([`processor.ts:355–410`](../../apps/server/src/ntbs/processor.ts#L355-L410)). One slow or hung platform call therefore blocks outcomes for every other NTBS thread handled by that adapter and can grow the event backlog. -Removing the duplicate event path as in S1 eliminates this issue. If the event path stays, route relevant events to per-request fibers with bounded concurrency; keep the per-request serialization at the durable response transition. +If this matters at observed volumes, route relevant events to fibers with bounded concurrency while retaining per-request serialization at the outcome transition. ### B9. Medium: unique requests have no resource bound -The API explicitly accepts unlimited concurrent distinct requests ([processor.ts](../../apps/server/src/ntbs/processor.ts#L75)). Each can fetch `origin`, create a worktree, run setup, start a full-access provider turn, and retain a monitor. A webhook replay or burst of legitimate messages can exhaust disk, git subprocesses, or provider capacity even though duplicate URIs are suppressed. +The API explicitly accepts unlimited concurrent distinct requests ([`processor.ts:69–82`](../../apps/server/src/ntbs/processor.ts#L69-L82)). Each can fetch `origin`, create a worktree, run setup, and start a full-access provider turn. A webhook burst can exhaust disk, git subprocesses, or provider capacity even though duplicate URIs are suppressed. -Put a configurable bound around accepted active requests, ideally at the durable claim/queue boundary so restarts do not discard queued work. At minimum, bound provisioning per project; concurrent `git fetch` and worktree setup for the same repository provide little benefit. +Put a configurable bound around active requests, ideally at a durable claim/queue boundary. At minimum, bound provisioning per project; concurrent fetches and worktree setup for the same repository provide little benefit. ### B10. Medium: invalid input fails after side effects instead of at admission -Because the comment-only input invariants are not decoded, an empty URI can collapse unrelated requests onto one dedup key, and over-limit text/attachments can be accepted through orchestration only to fail at the provider boundary after resources and a pending record exist. Validate before the durable claim as described in S5, and return a stable rejected outcome rather than relying on a later provider error. +Because [`NTBSInput` invariants](../../apps/server/src/ntbs/lifecycle.ts#L3-L37) are not decoded, an empty URI can collapse unrelated requests onto one dedup key, and over-limit text or attachments can reach provider validation after resources and a pending record exist. Validate before the durable claim as described in S5 and return a stable rejected outcome rather than relying on a later provider error. ### B11. Low: an exact-turn error can use another turn's error text -The processor carefully selects the turn by the recorded user-message ID, but for an errored turn it reads `thread.session.lastError`, which is thread-wide current session state ([processor.ts](../../apps/server/src/ntbs/processor.ts#L399)). If the thread later receives another turn, that message may describe the later session rather than the NTBS turn. Until errors are stored per turn, prefer the generic failure text over potentially incorrect detail, or only use `lastError` when the selected turn is also the current/latest turn. +The processor selects the turn by its recorded user-message ID, but for an errored turn it reads thread-wide `session.lastError` ([`processor.ts:263–309`](../../apps/server/src/ntbs/processor.ts#L263-L309)). If the thread later receives another turn, that text may describe the later session rather than the NTBS turn. Until errors are stored per turn, prefer generic failure text, or use `lastError` only when the selected turn is the current/latest turn. ### B12. Confidence gap: critical transitions are untested -The current tests do not cover a successful create/start/terminal-response lifecycle, missing-turn recovery, a response found remotely after local-save failure, concurrent completion versus timeout, duplicate concurrent deliveries, retry after turn-start failure, timeout interruption, or monitor cleanup. These are exactly the paths where the implementation carries custom locks and recovery logic. After consolidating the harness, cover those transitions with controllable deferred adapter calls and a test clock; that will also make it safe to remove the redundant ownership machinery. +The four current tests cover one no-assertion process call, unknown-event routing, durable redelivery deduplication, and ignoring an already-recorded response ([`processor.test.ts`](../../apps/server/src/ntbs/processor.test.ts), [`processor2.test.ts`](../../apps/server/src/ntbs/processor2.test.ts#L215-L281)). They do not cover a successful create/start/terminal-response lifecycle, missing-turn startup recovery, active-turn recovery, terminal-turn recovery, a response found remotely after local-save failure, concurrent recovery versus live completion, duplicate concurrent deliveries, or retry after turn-start failure. + +After consolidating the harness, cover those transitions with controllable deferred adapter calls. In particular, turn the existing missing-turn TODO into a test that proves recovery reuses the stored `userMessageId` and does not start a second turn for pending/running records ([`processor.test.ts:84–85`](../../apps/server/src/ntbs/processor.test.ts#L84-L85)). -I specifically did not flag a projection/publication race: the orchestration engine applies the projection in the same transaction before publishing each event to `streamDomainEvents`. I also did not treat best-effort setup-script failure or the documented temporary-branch leak as new NTBS bugs; both match existing bridge behavior and are explicit choices in the current code. +I specifically did not flag a projection/publication race: the orchestration engine applies projections in the same transaction before publishing each event to `streamDomainEvents`. I also did not treat best-effort setup-script failure or the documented temporary-branch leak as new NTBS bugs; both are explicit choices in the implementation ([`processor.ts:554–604`](../../apps/server/src/ntbs/processor.ts#L554-L604)). From 3976de8775b8a450437c745edb38dcd7cc82ab1b Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 16 Aug 2026 14:04:18 +0200 Subject: [PATCH 077/110] chore: renames and refactors --- apps/server/src/ntbs/adapter.ts | 10 +++++----- .../src/ntbs/{lifecycle.ts => exchange.ts} | 13 ++++++++---- apps/server/src/ntbs/processor.ts | 20 +++++++++---------- apps/server/src/ntbs/processor2.test.ts | 14 ++++++------- apps/server/src/ntbs/test-helpers.ts | 10 +++++----- 5 files changed, 36 insertions(+), 31 deletions(-) rename apps/server/src/ntbs/{lifecycle.ts => exchange.ts} (85%) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 95225774c6bf..89c97bd8c048 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -1,5 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; -import * as NTBS from "./lifecycle.ts"; +import * as NTBS from "./exchange.ts"; import { Context, Data, Effect } from "effect"; export class ThreadNotFound extends Data.TaggedError("ThreadNotFound") {} @@ -33,7 +33,7 @@ export interface NTBSAdapter { /** * Stores a lifecycle state. Does not perform any other business logic. */ - readonly save: (lifecycleEvent: NTBS.NTBSLifecycle) => Effect.Effect; + readonly save: (lifecycleEvent: NTBS.ExchangeState) => Effect.Effect; /** * Posts the working acknowledgement at the response destination, * described by the event. @@ -61,8 +61,8 @@ export interface NTBSAdapter { * Any lifecycle state means the request already has a T3 thread. */ readonly findByRequest: ( - request: NTBS.NTBSInput, - ) => Effect.Effect; + request: NTBS.Request, + ) => Effect.Effect; /** * Searches the response destination for a matching response previously * posted by this adapter. @@ -81,7 +81,7 @@ export interface NTBSAdapter { */ readonly findByThreadId: ( threadId: ThreadId, - ) => Effect.Effect; + ) => Effect.Effect; /** * Loads records that reached `ThreadCreated` but have no recorded * `ResponsePosted` state. diff --git a/apps/server/src/ntbs/lifecycle.ts b/apps/server/src/ntbs/exchange.ts similarity index 85% rename from apps/server/src/ntbs/lifecycle.ts rename to apps/server/src/ntbs/exchange.ts index ff64166f29db..e177b83453f9 100644 --- a/apps/server/src/ntbs/lifecycle.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -1,6 +1,6 @@ import type { ChatAttachment, MessageId, ThreadId } from "@t3tools/contracts"; -export type NTBSInput = { +export type Request = { /** * Adapter-encoded URI locating the originating platform message, * e.g. `discord:////` or @@ -36,8 +36,8 @@ export type NTBSInput = { attachments: ReadonlyArray; }; -export type ThreadEvent = NTBSInput & { - t3Data: { +export type ThreadEvent = Request & { + t3: { /** The T3 thread created by the lifecycle event */ threadId: ThreadId; /** @@ -62,4 +62,9 @@ export type ResponsePosted = ThreadEvent & { responseMessageId: string; }; -export type NTBSLifecycle = ThreadCreated | ResponsePosted; +/** + * The state of an exchange between an external platform and T3, from thread + * creation through final-response delivery. Adapters store the latest state to + * track progress and resume incomplete exchanges after a restart. + */ +export type ExchangeState = ThreadCreated | ResponsePosted; diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 0bd0f96676f3..1003cbf538df 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -8,7 +8,7 @@ import { type ProjectId, ThreadId, } from "@t3tools/contracts"; -import type * as NTBS from "./lifecycle.ts"; +import type * as NTBS from "./exchange.ts"; import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; @@ -35,7 +35,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; 2. Platform-specific inbound code: - Receives raw platform data from Jira, Discord, GitHub, or Teams. - Applies platform trigger and actor checks. - - Builds `NTBSInput

` and `T3Context`. + - Builds `Request` and `t3` context. - Calls the processor. 3. Adapter @@ -77,7 +77,7 @@ export interface NTBSProcessor { * or backpressure for the time being. This choice can be reviewed later. */ readonly process: ( - request: NTBS.NTBSInput, + request: NTBS.Request, t3Context: T3Context, ) => Effect.Effect; @@ -345,7 +345,7 @@ export const makeNTBSProcessor = ( }) .pipe(orFail("Failed recording the posted NTBS response")); - markResponsePosted(threadCreated.t3Data.userMessageId); + markResponsePosted(threadCreated.t3.userMessageId); }); /** @@ -378,7 +378,7 @@ export const makeNTBSProcessor = ( At the same time a thread may have different messages. We're only interested in the last user message that appears in the adapter records. */ - const userMessageId = recordedThread.t3Data.userMessageId; + const userMessageId = recordedThread.t3.userMessageId; yield* ensureUniqueOutcome( userMessageId, @@ -616,7 +616,7 @@ export const makeNTBSProcessor = ( threadCreated: NTBS.ThreadCreated, ): Effect.Effect => Effect.gen(function* () { - const { threadId, userMessageId } = threadCreated.t3Data; + const { threadId, userMessageId } = threadCreated.t3; const turn = yield* getTurn(threadId, userMessageId); @@ -665,7 +665,7 @@ export const makeNTBSProcessor = ( 4. Start the first T3 turn with that message ID, the snapshot, and attachments. 5. Attempt to post the acknowledgement independently. */ - const process = (request: NTBS.NTBSInput, t3Context: T3Context) => + const process = (request: NTBS.Request, t3Context: T3Context) => Effect.gen(function* () { /* In-flight dedup first. We check if the processor is *currently* @@ -702,7 +702,7 @@ export const makeNTBSProcessor = ( const threadCreated: NTBS.ThreadCreated = { ...request, state: "thread.created", - t3Data: { + t3: { threadId, userMessageId, }, @@ -749,8 +749,8 @@ export const makeNTBSProcessor = ( recoverThread(threadCreated).pipe( Effect.catch((cause) => Effect.logWarning("Failed recovering an NTBS thread", { - threadId: threadCreated.t3Data.threadId, - userMessageId: threadCreated.t3Data.userMessageId, + threadId: threadCreated.t3.threadId, + userMessageId: threadCreated.t3.userMessageId, cause, }), ), diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts index 81ea09709380..f9e5d227e81e 100644 --- a/apps/server/src/ntbs/processor2.test.ts +++ b/apps/server/src/ntbs/processor2.test.ts @@ -16,7 +16,7 @@ import { type NTBSAdapter, type NTBSResponse, } from "./adapter.ts"; -import type { NTBSInput, NTBSLifecycle, ThreadCreated } from "./lifecycle.ts"; +import type { Request, ExchangeState, ThreadCreated } from "./exchange.ts"; import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -85,7 +85,7 @@ class TestAdapterState extends Context.Service< TestAdapterState, { /** Lifecycle records keyed by T3 thread — seed before acting, inspect after. */ - readonly records: Map; + readonly records: Map; readonly postedAcks: Array; readonly postedResponses: Array<{ readonly record: ThreadCreated; @@ -99,7 +99,7 @@ class TestAdapterState extends Context.Service< TestAdapterState, Effect.gen(function* () { return { - records: new Map(), + records: new Map(), postedAcks: [], postedResponses: [], threadLookups: yield* Queue.unbounded(), @@ -118,7 +118,7 @@ const AdapterFromState = Layer.effect( const adapter: NTBSAdapter = { save: (lifecycleEvent) => Effect.sync(() => { - state.records.set(lifecycleEvent.t3Data.threadId, lifecycleEvent); + state.records.set(lifecycleEvent.t3.threadId, lifecycleEvent); }), acknowledge: (record) => Effect.sync(() => { @@ -200,16 +200,16 @@ const sessionSetEvent = (threadId: ThreadId): Effect.Effect }; }); -const makeRequest = (platformMessageId: string): NTBSInput => ({ +const makeRequest = (platformMessageId: string): Request => ({ sourceUri: platformMessageId, snapshot: "please look into this", attachments: [], }); -const recordedThread = (request: NTBSInput, threadId: ThreadId): ThreadCreated => ({ +const recordedThread = (request: Request, threadId: ThreadId): ThreadCreated => ({ ...request, state: "thread.created", - t3Data: { threadId, userMessageId: MessageId.make(`message-for-${threadId}`) }, + t3: { threadId, userMessageId: MessageId.make(`message-for-${threadId}`) }, }); describe("NTBSProcessor (layer harness)", () => { diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts index 36c0f3c7651e..58617e385a4e 100644 --- a/apps/server/src/ntbs/test-helpers.ts +++ b/apps/server/src/ntbs/test-helpers.ts @@ -7,7 +7,7 @@ import { ThreadId, VcsCreateWorktreeResult, } from "@t3tools/contracts"; -import type { NTBSInput, NTBSLifecycle, ThreadCreated } from "./lifecycle.ts"; +import type { Request, ExchangeState, ThreadCreated } from "./exchange.ts"; import type { T3Context } from "./processor.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { makeNTBSAdapterTag, ThreadNotFound, type NTBSResponse } from "./adapter.ts"; @@ -58,7 +58,7 @@ export const createGitLayerMock = () => { export const createAdapterRequest = ( uniqueId: string, ): { - request: NTBSInput; + request: Request; t3Context: T3Context; } => ({ request: { @@ -168,7 +168,7 @@ class TestAdapterState extends Context.Service< /** * Lifecycle records keyed by T3 thread. */ - readonly records: Map; + readonly records: Map; readonly postedAcks: Map; readonly postedResponses: Map< string, @@ -189,7 +189,7 @@ class TestAdapterState extends Context.Service< Effect.gen(function* () { return { // lifecycleEvents: [], - records: new Map(), + records: new Map(), postedAcks: new Map(), postedResponses: new Map< string, @@ -214,7 +214,7 @@ const TestAdapterFromState = Layer.effect( return { save: (event) => Effect.sync(() => { - adapterState.records.set(event.t3Data.threadId, event); + adapterState.records.set(event.t3.threadId, event); }), acknowledge: (state) => Effect.sync(() => { From a3324f6096294c7b3beb9313393fba0ec5f4f610 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 16 Aug 2026 15:11:30 +0200 Subject: [PATCH 078/110] feat: continue refactoring --- apps/server/src/ntbs/exchange.ts | 6 +- docs/planning/ntbs-todos.md | 216 ++++++++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 8 deletions(-) diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index e177b83453f9..45239a56b5a2 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -36,7 +36,7 @@ export type Request = { attachments: ReadonlyArray; }; -export type ThreadEvent = Request & { +type AcceptedRequest = Request & { t3: { /** The T3 thread created by the lifecycle event */ threadId: ThreadId; @@ -49,7 +49,7 @@ export type ThreadEvent = Request & { }; }; -export type ThreadCreated = ThreadEvent & { +export type ThreadCreated = AcceptedRequest & { /** * T3 has created the new thread and the adapter has recorded its relationship * to the platform request. The first turn may not have started yet. @@ -57,7 +57,7 @@ export type ThreadCreated = ThreadEvent & { state: "thread.created"; }; -export type ResponsePosted = ThreadEvent & { +export type ResponsePosted = AcceptedRequest & { state: "thread.response.posted"; responseMessageId: string; }; diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index af1b75af9390..02ea821e499c 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -1,9 +1,215 @@ -# Is the whole NTBS contract a state machine under disguise? +# Is the whole NTBS contract a state machine in disguise? -`NTBSAdapter.save` is a generic write: "store this record, whatever it is". +`NTBSAdapter.save` is currently a generic write: "store this state, whatever it +is." Nothing prevents an invalid transition such as `ResponsePosted -> +ThreadCreated`, and nothing in the contract requires two deliveries of the same +external request to converge on one exchange. -Nothing enforces/prevents bad transitions (e.g. `ResponsePosted -> ThreadCreated`) or two records of the same external request. +This shifts much of the lifecycle choreography into the processor. It repeatedly +checks adapter storage, T3 projections, process-local locks, and the external +platform to determine what has already happened and what is safe to do next. -A byproduct of this is that lots of choreography is shifted as a responsibility of the processor itself which has to continuously ask whether it's not dealing with a dedup and such. +The current implementation is therefore a multi-step process manager represented +by only two stored variants: -Idea: could `save` be instead replaced with a proper state machine that uniquely indexed on the request URI? +```text +Request received no stored exchange state +Thread created no stored exchange state until setup finishes +ThreadCreated saved thread.created +Turn started thread.created +Turn completed thread.created +Reply posted thread.created until the subsequent save succeeds +ResponsePosted saved thread.response.posted +``` + +`ThreadCreated` consequently describes several materially different situations: + +- the first turn was never started; +- the turn is pending or running; +- the turn finished but its reply has not been posted; +- the reply was posted but the processor stopped before recording it. + +That ambiguity is why `recoverThread` has to query T3 for the matching turn and +branch on whether it is missing, active, or terminal. It is also why +`findMatchingResponseMessage` has to inspect the external platform before every +post attempt. + +## Where the two-state model causes real problems + +### There is no durable admission state + +`process` calls `findByRequest`, creates the worktree and T3 thread, and only then +saves `ThreadCreated`. `inFlightRequests` suppresses concurrent delivery only +inside one processor instance. Two processes can both observe no state and create +duplicate work, while a process exit after thread creation but before the save +leaves an orphaned thread that redelivery cannot discover. + +A pre-thread state can close this gap only if it is created through an atomic +insert-if-absent operation keyed by `sourceUri`. A generic read followed by a +generic save is not sufficient. + +### `ThreadCreated` does not identify the next recovery action + +The processor saves `ThreadCreated` before dispatching `thread.turn.start`. If +turn start fails, a later delivery finds an existing state and returns without +reconciling it. Startup recovery does reconcile the same state, but restarting +the server should not be the ordinary retry mechanism. + +The same state remains stored after the turn starts and after it finishes. The +processor can recover only by consulting T3 and inferring which transition was +missed. + +### Reply delivery has an unavoidable cross-system gap + +The processor posts a reply to the external platform and then saves +`ResponsePosted`. Those operations cannot share a transaction. If posting +succeeds and the save fails, adapter storage still says `ThreadCreated` even +though the user has already received the reply. + +The current platform lookup is a useful reconciliation mechanism, but the reply +payload is recomputed from T3 on every attempt. That payload can drift between +attempts, making content-based matching an unreliable idempotency boundary. + +## Argument for a richer state machine + +A richer durable model could: + +- claim a source request atomically before creating resources; +- give every incomplete state one explicit recovery action; +- make ordinary redelivery, startup recovery, and live T3 events call the same + reconciliation path; +- persist the exact terminal response before attempting external delivery; +- make legal transitions explicit and prevent backwards writes; +- support atomic compare-and-set transitions across multiple processor + instances; +- make adapter conformance and crash-window behavior testable. + +The adapter contract would express operations such as `claim` and an atomic +expected-state transition instead of accepting any `ExchangeState` through +`save`. + +## Argument against mirroring every observed step + +A literal state sequence might look like this: + +```text +RequestReceived +-> ThreadCreated +-> TurnStarted +-> TurnCompleted +-> ReplySent +``` + +This identifies the hidden workflow, but it is not quite the right durable +model. `TurnStarted` and `TurnCompleted` are already durable facts owned by T3. +Copying them into adapter storage creates two sources of truth that cannot be +updated atomically. + +For example, persisting `TurnStarted` before dispatch can claim that a turn +started when it did not. Dispatching first and persisting afterward leaves a +window in which the turn exists but the exchange still says `ThreadCreated`. +Adding the state moves the ambiguity without eliminating it. The same problem +applies to `TurnCompleted` and `ReplySent`. + +Some deduplication also remains inherent regardless of the number of states: + +- source platforms deliver events at least once, so inbound requests require a + durable idempotency key; +- adapter storage and T3 cannot share a transaction, so their state must be + reconciled after interruption; +- adapter storage and an external posting API cannot share a transaction, so + reply delivery requires an idempotency key or a platform reconciliation step; +- startup recovery and live events can race, so state transitions need atomicity + or serialization even when their states are more precise. + +Acknowledgement delivery is also intentionally independent of final-response +delivery. It should not become a required step in one linear exchange state +machine merely to make the sequence appear complete. + +## Refined proposal: persist coordinator states + +The shared state should describe NTBS-owned handoffs and recovery decisions, +rather than duplicate T3's internal thread and turn state: + +```text +RequestClaimed +-> ThreadCreated +-> AwaitingOutcome +-> ReplyPending +-> ReplyPosted +``` + +### `RequestClaimed` + +The adapter has atomically claimed `sourceUri` for processing. This state must +retain everything needed to recover thread provisioning from a cold start, +including the request, thread target, and stable planned identifiers. "Claimed" +is more precise than "received": the processor receives only requests that have +already passed platform trigger and actor checks, and duplicate receipt must not +imply ownership by a second processor. + +### `ThreadCreated` + +The planned T3 thread exists and is correlated with the external request. A +reconciler in `RequestClaimed` must be able to determine whether creation already +succeeded before retrying it, which requires assigning stable identifiers before +the side effect. + +### `AwaitingOutcome` + +The processor is responsible for ensuring that the planned turn is requested and +for observing its terminal state. T3 remains the source of truth for whether the +turn is missing, pending, running, completed, failed, or cancelled. This avoids a +stale adapter-owned copy of `TurnStarted` while still giving recovery a clear +action. + +### `ReplyPending` + +T3 has reached a terminal outcome and the exact `NTBSResponse` payload has been +stored together with a stable delivery key. Recovery posts this stored payload +rather than recomputing it. Persisting reply intent before posting narrows +platform inspection to the genuine post-succeeded/save-failed window. + +### `ReplyPosted` + +The external platform has accepted the final reply and its message identifier is +stored. This is the terminal state. "Posted" is preferable to "sent" because +"sent" can describe an attempt that produced no durable platform message. + +Each state then has one reconciliation rule: + +```text +RequestClaimed ensure the planned thread exists +ThreadCreated ensure the planned turn is requested +AwaitingOutcome inspect T3 and materialize a terminal response +ReplyPending post the stored response idempotently +ReplyPosted do nothing +``` + +`process`, startup recovery, and relevant T3 events should all load the exchange +and invoke this same reconciler. Their different triggers should not produce +different lifecycle semantics. + +## Open design questions + +- Which thread, message, worktree, and command identifiers must be allocated and + stored at claim time to make provisioning safely repeatable? +- Does `RequestClaimed` also retain `projectId` and `baseRef`, which are currently + passed separately and discarded after thread creation? +- Should transitions use compare-and-set on the expected state, a monotonically + increasing version, or both? +- Can every platform provide an idempotency key for reply creation, or must some + adapters search for an already-posted reply during recovery? +- Is `ReplyPending` sufficient as an outbox, or should reply delivery be a + separate durable entity with its own retry metadata? +- How should permanently failed provisioning become a terminal response rather + than an exchange that remains claimed forever? +- Which acknowledgement metadata belongs in adapter-specific storage without + becoming a blocking shared lifecycle state? +- Should one processor instance lease a claimed exchange while reconciling it, + or are atomic transitions and idempotent effects sufficient? + +The central design requirement is not merely to add more union members. The +state machine must claim requests atomically, store intent before non-atomic +effects, keep T3 authoritative for T3-owned facts, and make every incomplete +state safely reconcilable. From b245310331fdcda032628bbae50ee9d20b2ef4ec Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 16 Aug 2026 16:44:21 +0200 Subject: [PATCH 079/110] feat: refine next phases of work --- docs/planning/ntbs-todos.md | 123 ++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index 02ea821e499c..e54344a054be 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -213,3 +213,126 @@ The central design requirement is not merely to add more union members. The state machine must claim requests atomically, store intent before non-atomic effects, keep T3 authoritative for T3-owned facts, and make every incomplete state safely reconcilable. + +--- + +## Review feedback (Claude, 2026-08-16) + +Diagnosis verified against `processor.ts` / `adapter.ts`: the three problem +sections above are real, and the coordinator-state direction is right. +Amendments below, one decision each — outcomes go in the decision log at the +bottom. + +### 1. Cut `AwaitingOutcome` (five states → four) + +`AwaitingOutcome` is `TurnRequested` by another name and fails this doc's own +argument against `TurnStarted`: its entry transition pairs a non-atomic T3 +dispatch with an adapter save, so it lies in one order and leaves a crash +window in the other. It carries no new data, and its reconcile rule collapses +into `ThreadCreated`'s — "ensure the turn is requested" already requires +`getTurn`, and after `getTurn` you know whether to start, wait, or materialize +the outcome. That is exactly today's `recoverThread` branch. Resulting model: + +```text +RequestClaimed request + T3 context + planned IDs ensure thread/worktree exist +ThreadCreated + confirmed T3 IDs ensure turn requested; on + terminal outcome write ReplyPending +ReplyPending + exact NTBSResponse + delivery key post idempotently +ReplyPosted + platform message ID do nothing +``` + +### 2. De-scope multi-instance; keep expected-state CAS + +The real deployment is one Node process per home dir. Leases and cross-instance +coordination solve a deployment that does not exist — answer "no" to both. +Keep expected-state CAS anyway: `transition(from, to)` is a one-line `WHERE` +clause in SQLite, makes backwards writes impossible at the storage layer, and +gives the conformance suite something to assert. Contract becomes `claim` +(insert-if-absent, returns new-or-existing) + `transition` (CAS with a +stale-state signal) + lookups, replacing generic `save`. + +### 3. Reconcile-on-redelivery is a quick win, independent of the schema + +Today, when `process` finds an existing record it returns — so a failed turn +start stays stuck until a server restart. Making redelivery call the same +reconciler as startup recovery and live events fixes that hole now, with no +contract change. Land it first. + +### 4. Proposed answers to the open questions + +- **IDs at claim time:** `threadId`, `userMessageId`, branch name. Command IDs + can be re-minted per attempt; both effects are verify-before-retry. +- **`projectId`/`baseRef`:** yes, in the claim payload. They are provisioning + inputs that go dead once `ThreadCreated` is reached, so no sync burden. +- **CAS vs version:** expected-state CAS only; states are few and monotone. +- **Platform idempotency keys:** none exist for Jira/Discord/GitHub message + creation. Recovery searches for the stored exact payload (plus a delivery-key + marker where the platform tolerates one) — reliable precisely because the + payload is persisted, not recomputed. +- **Separate outbox entity:** no. `ReplyPending` is the outbox; one reply per + exchange; retry metadata is adapter-local. +- **Permanently failed provisioning:** after bounded attempts, materialize + `ReplyPending` with a failure text so the requester hears about it through + the normal delivery pipe. Terminal `Abandoned` only when posting itself is + impossible. Invariant: every claim ends in `ReplyPosted` or `Abandoned`. +- **Acknowledgement metadata:** adapter-local, never a blocking shared state. + (Note: today an ack is only attempted inside `process`, never on recovery.) +- **Leases:** no — idempotent reconciliation plus the in-process outcome lock. + +### Invariants to record regardless of the decisions + +- Turn-start idempotency rests on `getTurn` being read-your-writes at reconcile + time; a lagging projection would double-start a turn. +- Provisioning recovery makes worktree creation reentrant: the reconciler must + handle "branch already exists from a pre-crash attempt" by reusing it. + +## Decision log + +Working through the amendments one topic at a time; record each outcome here. + +- [x] 1. State model: cut `AwaitingOutcome`, four durable states — **decided + 2026-08-16**: storage lies less but says less; the `getTurn` query it + forces is cheap, local, and already written. +- [x] 2. Contract invariants — **decided 2026-08-16**, stated at behavior + level: (a) one exchange per `sourceUri` for its whole life; duplicate + deliveries join it, never create another; (b) forward-only lifecycle — + moving backwards is an error, not a write (failure may jump ahead to + `ReplyPending`). No leases, no multi-process machinery. How adapters + enforce the invariants is implementation, decided later. +- [x] 3. Recovery ownership — **decided 2026-08-16**, supersedes amendment 3: + duplicate deliveries are pure dedup (drop, no repair) because platform + redelivery is not a guaranteed retry mechanism. The processor owns + recovery: one reconciler, three triggers — startup, relevant T3 events, + and a periodic sweep over incomplete exchanges. The sweep is the + guarantee; live events are the fast path. +- [x] 4a. Claim contents — **decided 2026-08-16** (tentative, revisit if + implementation fights it): the claim stores the full request + (`sourceUri`, snapshot, attachments), the T3 context (`projectId`, + `baseRef`), and pre-minted planned IDs (`threadId`, `userMessageId`, + branch name) so a cold-start sweep can redo provisioning without the + original webhook and can detect an already-created thread instead of + duplicating it. +- [x] 4b. Reply delivery — **decided 2026-08-16**: identity and content are + separate. The adapter must answer with **certainty** whether its reply + for this exact exchange exists on the platform, via structural + attribution (Discord reply referencing the trigger message, Jira comment + linkage), or an embedded exchange UUID as last resort — never content + matching, since identical texts legitimately recur. The verbatim payload + persisted in `ReplyPending` is only the content, so retries post the + same thing. Recovery: check existence → post if absent → record posted. +- [x] 4c. Failure path — **decided 2026-08-16**: states track delivery, not + outcome quality. Any permanent failure (provisioning, turn, lost thread) + becomes a failure-typed reply through the normal pipe after bounded + attempts; delivering it ends the exchange in `ReplyPosted`, a completed + job from the processor's view. `Undeliverable` (renamed from + `Abandoned`) is the only other terminal state, entered solely from + `ReplyPending` when posting itself is given up: the stored verbatim + reply plus the cause, never retried again. Every exchange ends + `ReplyPosted` or `Undeliverable`. +- [x] 4d. Acknowledgement — **decided 2026-08-16**: the processor has no + business knowing whether the ack succeeded; no exchange state waits on + it. The adapter records the ack message ID locally and may deliver the + final reply by editing that ack instead of posting fresh — a rendering + choice it owns. An adapter doing so must count the edited ack as the + existing reply in its certainty check (4b). Crash before ack ⇒ ack is + simply never posted; the final reply is unaffected. From 87f248976be64f07c97561174633f65c7bb901e1 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Sun, 16 Aug 2026 23:14:07 +0200 Subject: [PATCH 080/110] chore: start refactoring the ntbs state --- apps/server/src/ntbs/exchange.ts | 70 +++++- docs/planning/ntbs-todos.md | 403 +++++++------------------------ 2 files changed, 151 insertions(+), 322 deletions(-) diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index 45239a56b5a2..688fbf2b8130 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -36,7 +36,10 @@ export type Request = { attachments: ReadonlyArray; }; -type AcceptedRequest = Request & { +/** + * The base data type common to all member of ExchangeState + */ +type ExchangeStateBase = Request & { t3: { /** The T3 thread created by the lifecycle event */ threadId: ThreadId; @@ -49,17 +52,57 @@ type AcceptedRequest = Request & { }; }; -export type ThreadCreated = AcceptedRequest & { - /** - * T3 has created the new thread and the adapter has recorded its relationship - * to the platform request. The first turn may not have started yet. - */ - state: "thread.created"; +/** + * The platform inbound code (Jira Webhook e.g.) admitted the request, + * trigger and actor checks passed, and the processor records the request + * as being claimed by the system. + * + * From here, the processor alone drives the exchange to a terminal state. + */ +type RequestClaimed = ExchangeStateBase & { + state: "request-claimed"; +}; + +/** + * T3 has created the new thread and the adapter has recorded its relationship + * to the platform request. The first turn may not have started yet. + * Turn existence and progress are T3-owned. + */ +type ThreadCreated = ExchangeStateBase & { + state: "thread-created"; }; -export type ResponsePosted = AcceptedRequest & { - state: "thread.response.posted"; - responseMessageId: string; +/** + * T3 reached a terminal outcome; the exact reply payload is stored + * verbatim so every posting attempt sends the same content. + */ +type ReplyPending = ExchangeStateBase & { + state: "reply-pending"; + reply: string; +}; + +/** + * Terminal state. + * The platform accepted the reply; its message ID is stored. + */ +type ReplyPosted = ExchangeStateBase & { + state: "reply-posted"; + reply: string; + replySourceUuri: string; +}; + +/** + * Terminal state. + * A finished reply exists but posting was given up after bounded attempts. + * Stores the undelivered payload and the cause. + * Common causes could be: the original discussion or message has been deleted + * or locked (Jira/Github issue, Discord thread), the bot has been kicked, etc. + * The tombstone keeps dedup intact and stops the processor from retrying together. + */ +type Undeliverable = ExchangeStateBase & { + state: "undeliverable"; + reply: string; + cause: unknown; }; /** @@ -67,4 +110,9 @@ export type ResponsePosted = AcceptedRequest & { * creation through final-response delivery. Adapters store the latest state to * track progress and resume incomplete exchanges after a restart. */ -export type ExchangeState = ThreadCreated | ResponsePosted; +export type ExchangeState = + | RequestClaimed + | ThreadCreated + | ReplyPending + | ReplyPosted + | Undeliverable; diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index e54344a054be..cbe689a9223b 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -1,338 +1,119 @@ -# Is the whole NTBS contract a state machine in disguise? +# NTBS exchange lifecycle -`NTBSAdapter.save` is currently a generic write: "store this state, whatever it -is." Nothing prevents an invalid transition such as `ResponsePosted -> -ThreadCreated`, and nothing in the contract requires two deliveries of the same -external request to converge on one exchange. +**Status:** decided 2026-08-16 · supersedes the two-state `ExchangeState` -This shifts much of the lifecycle choreography into the processor. It repeatedly -checks adapter storage, T3 projections, process-local locks, and the external -platform to determine what has already happened and what is safe to do next. +The old model stored only `ThreadCreated | ResponsePosted` through a generic `save`, so the processor had to re-derive "what already happened" from adapter storage, T3 projections, process-local locks, and the external platform on every step. The settled design replaces it with a claimed, forward-only exchange machine with one reconciler. -The current implementation is therefore a multi-step process manager represented -by only two stored variants: +## States ```text -Request received no stored exchange state -Thread created no stored exchange state until setup finishes -ThreadCreated saved thread.created -Turn started thread.created -Turn completed thread.created -Reply posted thread.created until the subsequent save succeeds -ResponsePosted saved thread.response.posted +RequestClaimed -> ThreadCreated -> ReplyPending -> ReplyPosted + \ + -> Undeliverable ``` -`ThreadCreated` consequently describes several materially different situations: - -- the first turn was never started; -- the turn is pending or running; -- the turn finished but its reply has not been posted; -- the reply was posted but the processor stopped before recording it. - -That ambiguity is why `recoverThread` has to query T3 for the matching turn and -branch on whether it is missing, active, or terminal. It is also why -`findMatchingResponseMessage` has to inspect the external platform before every -post attempt. - -## Where the two-state model causes real problems - -### There is no durable admission state - -`process` calls `findByRequest`, creates the worktree and T3 thread, and only then -saves `ThreadCreated`. `inFlightRequests` suppresses concurrent delivery only -inside one processor instance. Two processes can both observe no state and create -duplicate work, while a process exit after thread creation but before the save -leaves an orphaned thread that redelivery cannot discover. - -A pre-thread state can close this gap only if it is created through an atomic -insert-if-absent operation keyed by `sourceUri`. A generic read followed by a -generic save is not sufficient. - -### `ThreadCreated` does not identify the next recovery action - -The processor saves `ThreadCreated` before dispatching `thread.turn.start`. If -turn start fails, a later delivery finds an existing state and returns without -reconciling it. Startup recovery does reconcile the same state, but restarting -the server should not be the ordinary retry mechanism. - -The same state remains stored after the turn starts and after it finishes. The -processor can recover only by consulting T3 and inferring which transition was -missed. - -### Reply delivery has an unavoidable cross-system gap - -The processor posts a reply to the external platform and then saves -`ResponsePosted`. Those operations cannot share a transaction. If posting -succeeds and the save fails, adapter storage still says `ThreadCreated` even -though the user has already received the reply. - -The current platform lookup is a useful reconciliation mechanism, but the reply -payload is recomputed from T3 on every attempt. That payload can drift between -attempts, making content-based matching an unreliable idempotency boundary. - -## Argument for a richer state machine - -A richer durable model could: - -- claim a source request atomically before creating resources; -- give every incomplete state one explicit recovery action; -- make ordinary redelivery, startup recovery, and live T3 events call the same - reconciliation path; -- persist the exact terminal response before attempting external delivery; -- make legal transitions explicit and prevent backwards writes; -- support atomic compare-and-set transitions across multiple processor - instances; -- make adapter conformance and crash-window behavior testable. - -The adapter contract would express operations such as `claim` and an atomic -expected-state transition instead of accepting any `ExchangeState` through -`save`. - -## Argument against mirroring every observed step - -A literal state sequence might look like this: - -```text -RequestReceived --> ThreadCreated --> TurnStarted --> TurnCompleted --> ReplySent -``` - -This identifies the hidden workflow, but it is not quite the right durable -model. `TurnStarted` and `TurnCompleted` are already durable facts owned by T3. -Copying them into adapter storage creates two sources of truth that cannot be -updated atomically. - -For example, persisting `TurnStarted` before dispatch can claim that a turn -started when it did not. Dispatching first and persisting afterward leaves a -window in which the turn exists but the exchange still says `ThreadCreated`. -Adding the state moves the ambiguity without eliminating it. The same problem -applies to `TurnCompleted` and `ReplySent`. - -Some deduplication also remains inherent regardless of the number of states: - -- source platforms deliver events at least once, so inbound requests require a - durable idempotency key; -- adapter storage and T3 cannot share a transaction, so their state must be - reconciled after interruption; -- adapter storage and an external posting API cannot share a transaction, so - reply delivery requires an idempotency key or a platform reconciliation step; -- startup recovery and live events can race, so state transitions need atomicity - or serialization even when their states are more precise. - -Acknowledgement delivery is also intentionally independent of final-response -delivery. It should not become a required step in one linear exchange state -machine merely to make the sequence appear complete. - -## Refined proposal: persist coordinator states - -The shared state should describe NTBS-owned handoffs and recovery decisions, -rather than duplicate T3's internal thread and turn state: - -```text -RequestClaimed --> ThreadCreated --> AwaitingOutcome --> ReplyPending --> ReplyPosted -``` - -### `RequestClaimed` - -The adapter has atomically claimed `sourceUri` for processing. This state must -retain everything needed to recover thread provisioning from a cold start, -including the request, thread target, and stable planned identifiers. "Claimed" -is more precise than "received": the processor receives only requests that have -already passed platform trigger and actor checks, and duplicate receipt must not -imply ownership by a second processor. - -### `ThreadCreated` - -The planned T3 thread exists and is correlated with the external request. A -reconciler in `RequestClaimed` must be able to determine whether creation already -succeeded before retrying it, which requires assigning stable identifiers before -the side effect. - -### `AwaitingOutcome` - -The processor is responsible for ensuring that the planned turn is requested and -for observing its terminal state. T3 remains the source of truth for whether the -turn is missing, pending, running, completed, failed, or cancelled. This avoids a -stale adapter-owned copy of `TurnStarted` while still giving recovery a clear -action. - -### `ReplyPending` - -T3 has reached a terminal outcome and the exact `NTBSResponse` payload has been -stored together with a stable delivery key. Recovery posts this stored payload -rather than recomputing it. Persisting reply intent before posting narrows -platform inspection to the genuine post-succeeded/save-failed window. - -### `ReplyPosted` - -The external platform has accepted the final reply and its message identifier is -stored. This is the terminal state. "Posted" is preferable to "sent" because -"sent" can describe an attempt that produced no durable platform message. - -Each state then has one reconciliation rule: +- **`RequestClaimed`** — the platform inbound code admitted the request (trigger and actor checks passed) and the processor recorded the claim; from here the processor alone drives the exchange to a terminal state, and redeliveries change nothing. Carries the full request (`sourceUri`, snapshot, attachments), the T3 context (`projectId`, `baseRef`), and pre-minted planned IDs (`threadId`, `userMessageId`, branch name) so a cold start can redo provisioning without the original webhook and detect an already-created thread instead of duplicating it. +- **`ThreadCreated`** — the planned thread exists; the IDs are confirmed facts. No turn state is stored: turn existence and progress are T3-owned. +- **`ReplyPending`** — T3 reached a terminal outcome; the exact reply payload is stored verbatim so every posting attempt sends the same content. +- **`ReplyPosted`** — terminal. The platform accepted the reply; its message ID is stored. +- **`Undeliverable`** — terminal, entered only from `ReplyPending`: a + finished reply exists but posting was given up after bounded attempts. + Stores the undelivered payload and the cause. The tombstone keeps dedup + intact and stops the sweep from retrying forever. + +## Invariants + +- One exchange per `sourceUri`, for its whole life. Duplicate deliveries join + it, never create another; they are pure dedup and trigger no repair. +- Forward-only lifecycle: moving backwards is an error, not a write. Failure + may jump ahead to `ReplyPending`. +- States track delivery, not outcome quality. Answer, failure, or + cancellation is data in the reply payload, never a state. Every exchange + ends in `ReplyPosted` or `Undeliverable`. +- T3 stays authoritative for T3-owned facts — no `TurnStarted` or + `AwaitingOutcome` copies in adapter storage. +- Turn-start idempotency rests on `getTurn` being read-your-writes at + reconcile time; provisioning recovery must treat worktree creation as + reentrant (the branch may already exist from a pre-crash attempt). + +## Recovery + +The processor owns recovery. One reconciler, three triggers: startup, +relevant T3 events, and a periodic sweep over incomplete exchanges. The sweep +is the guarantee; live events are only the fast path. Platform redelivery is +not a retry mechanism. + +## Pure decider + +The branching rules are one pure function, +`stored state + retrieved observation (+ attempts) -> next action`, with +companion transition constructors that turn an action's _result_ into the +next stored state. Decision and transition stay separate so no state ever +records an effect that has not happened. Each state names the single +observation to fetch first: ```text -RequestClaimed ensure the planned thread exists -ThreadCreated ensure the planned turn is requested -AwaitingOutcome inspect T3 and materialize a terminal response -ReplyPending post the stored response idempotently -ReplyPosted do nothing +RequestClaimed planned thread exists? Provision | AdvanceToThreadCreated +ThreadCreated turn missing | active | terminal(r) StartTurn | Wait | StoreReply(r) +ReplyPending my reply on platform? no | yes(id) Post | RecordPosted(id) | GiveUp(cause) +ReplyPosted / Undeliverable Done ``` -`process`, startup recovery, and relevant T3 events should all load the exchange -and invoke this same reconciler. Their different triggers should not produce -different lifecycle semantics. +`StartTurn` persists no exchange transition — turn existence is T3's fact. +The bounded-retry give-up rule lives inside the decider so it is testable. +Orchestration proper — scheduling the triggers, fetching observations, +executing effects, the in-process outcome lock, persisting transitions — +stays in the processor. -## Open design questions +## Reply delivery -- Which thread, message, worktree, and command identifiers must be allocated and - stored at claim time to make provisioning safely repeatable? -- Does `RequestClaimed` also retain `projectId` and `baseRef`, which are currently - passed separately and discarded after thread creation? -- Should transitions use compare-and-set on the expected state, a monotonically - increasing version, or both? -- Can every platform provide an idempotency key for reply creation, or must some - adapters search for an already-posted reply during recovery? -- Is `ReplyPending` sufficient as an outbox, or should reply delivery be a - separate durable entity with its own retry metadata? -- How should permanently failed provisioning become a terminal response rather - than an exchange that remains claimed forever? -- Which acknowledgement metadata belongs in adapter-specific storage without - becoming a blocking shared lifecycle state? -- Should one processor instance lease a claimed exchange while reconciling it, - or are atomic transitions and idempotent effects sufficient? +Identity and content are separate: -The central design requirement is not merely to add more union members. The -state machine must claim requests atomically, store intent before non-atomic -effects, keep T3 authoritative for T3-owned facts, and make every incomplete -state safely reconcilable. +- **Identity**: the adapter must answer with certainty whether its reply for + this exact exchange exists on the platform — structural attribution + (Discord reply referencing the trigger message, Jira comment linkage) or an + embedded exchange UUID as last resort. Never content matching; identical + texts legitimately recur. +- **Content**: the verbatim payload stored in `ReplyPending`, so retries post + the same thing. ---- +Delivery is: check existence -> post if absent -> record posted. -## Review feedback (Claude, 2026-08-16) - -Diagnosis verified against `processor.ts` / `adapter.ts`: the three problem -sections above are real, and the coordinator-state direction is right. -Amendments below, one decision each — outcomes go in the decision log at the -bottom. - -### 1. Cut `AwaitingOutcome` (five states → four) - -`AwaitingOutcome` is `TurnRequested` by another name and fails this doc's own -argument against `TurnStarted`: its entry transition pairs a non-atomic T3 -dispatch with an adapter save, so it lies in one order and leaves a crash -window in the other. It carries no new data, and its reconcile rule collapses -into `ThreadCreated`'s — "ensure the turn is requested" already requires -`getTurn`, and after `getTurn` you know whether to start, wait, or materialize -the outcome. That is exactly today's `recoverThread` branch. Resulting model: - -```text -RequestClaimed request + T3 context + planned IDs ensure thread/worktree exist -ThreadCreated + confirmed T3 IDs ensure turn requested; on - terminal outcome write ReplyPending -ReplyPending + exact NTBSResponse + delivery key post idempotently -ReplyPosted + platform message ID do nothing -``` +## Failure path -### 2. De-scope multi-instance; keep expected-state CAS +Any permanent failure (provisioning, turn, lost thread) becomes a +failure-typed reply through the normal delivery pipe after bounded attempts; +delivering it ends the exchange in `ReplyPosted` — a completed job from the +processor's view. Only when posting itself is given up does the exchange end +`Undeliverable`. -The real deployment is one Node process per home dir. Leases and cross-instance -coordination solve a deployment that does not exist — answer "no" to both. -Keep expected-state CAS anyway: `transition(from, to)` is a one-line `WHERE` -clause in SQLite, makes backwards writes impossible at the storage layer, and -gives the conformance suite something to assert. Contract becomes `claim` -(insert-if-absent, returns new-or-existing) + `transition` (CAS with a -stale-state signal) + lookups, replacing generic `save`. +## Acknowledgement -### 3. Reconcile-on-redelivery is a quick win, independent of the schema +The processor never learns whether the ack succeeded; no exchange state waits +on it. The adapter records the ack message ID locally and may deliver the +final reply by editing that ack instead of posting fresh — a rendering choice +it owns. An adapter doing so must count the edited ack as the existing reply +in its certainty check. A crash before the ack means it is simply never +posted; the final reply is unaffected. -Today, when `process` finds an existing record it returns — so a failed turn -start stays stuck until a server restart. Making redelivery call the same -reconciler as startup recovery and live events fixes that hole now, with no -contract change. Land it first. +## Adapter contract (shape, not API) -### 4. Proposed answers to the open questions +Operations the contract must express: claim (duplicates join the existing +exchange), persist-transition (forward-only), load-incomplete for the sweep, +the reply-existence certainty check, post-reply, and fire-and-forget ack. +Dependencies point at the platform client and storage only — never at the +processor. No leases, no multi-process machinery: the real deployment is one +server process. How an adapter enforces the invariants is implementation, +decided during the build. -- **IDs at claim time:** `threadId`, `userMessageId`, branch name. Command IDs - can be re-minted per attempt; both effects are verify-before-retry. -- **`projectId`/`baseRef`:** yes, in the claim payload. They are provisioning - inputs that go dead once `ThreadCreated` is reached, so no sync burden. -- **CAS vs version:** expected-state CAS only; states are few and monotone. -- **Platform idempotency keys:** none exist for Jira/Discord/GitHub message - creation. Recovery searches for the stored exact payload (plus a delivery-key - marker where the platform tolerates one) — reliable precisely because the - payload is persisted, not recomputed. -- **Separate outbox entity:** no. `ReplyPending` is the outbox; one reply per - exchange; retry metadata is adapter-local. -- **Permanently failed provisioning:** after bounded attempts, materialize - `ReplyPending` with a failure text so the requester hears about it through - the normal delivery pipe. Terminal `Abandoned` only when posting itself is - impossible. Invariant: every claim ends in `ReplyPosted` or `Abandoned`. -- **Acknowledgement metadata:** adapter-local, never a blocking shared state. - (Note: today an ack is only attempted inside `process`, never on recovery.) -- **Leases:** no — idempotent reconciliation plus the in-process outcome lock. +## Build order -### Invariants to record regardless of the decisions +Model → contract → orchestration; each phase leaves the previous one settled. -- Turn-start idempotency rests on `getTurn` being read-your-writes at reconcile - time; a lagging projection would double-start a turn. -- Provisioning recovery makes worktree creation reentrant: the reconciler must - handle "branch already exists from a pre-crash attempt" by reusing it. +1. **Exchange (the model).** The five states with their decided contents. Transition constructors as the only way to build each state from its predecessor plus an effect result. The observation and action vocabularies, and the pure decider with its give-up rule. Pure table tests for decider and transitions — no Effect scaffolding. -## Decision log +2. **Adapter (the contract).** Reshape the interface around the model per "Adapter contract" above. Update the in-memory test adapter. -Working through the amendments one topic at a time; record each outcome here. +3. **Processor (orchestration).** Collapse `process` / `recoverThread` / `processT3Event` into one loop: load → fetch the state's observation → decide → execute → persist. Admission becomes claim-then-reconcile. Add the periodic sweep as the third trigger beside startup and T3 events. Keep the outcome lock; review whether `inFlightRequests` still earns its place. Crash-window tests drive the real loop against the in-memory adapter. -- [x] 1. State model: cut `AwaitingOutcome`, four durable states — **decided - 2026-08-16**: storage lies less but says less; the `getTurn` query it - forces is cheap, local, and already written. -- [x] 2. Contract invariants — **decided 2026-08-16**, stated at behavior - level: (a) one exchange per `sourceUri` for its whole life; duplicate - deliveries join it, never create another; (b) forward-only lifecycle — - moving backwards is an error, not a write (failure may jump ahead to - `ReplyPending`). No leases, no multi-process machinery. How adapters - enforce the invariants is implementation, decided later. -- [x] 3. Recovery ownership — **decided 2026-08-16**, supersedes amendment 3: - duplicate deliveries are pure dedup (drop, no repair) because platform - redelivery is not a guaranteed retry mechanism. The processor owns - recovery: one reconciler, three triggers — startup, relevant T3 events, - and a periodic sweep over incomplete exchanges. The sweep is the - guarantee; live events are the fast path. -- [x] 4a. Claim contents — **decided 2026-08-16** (tentative, revisit if - implementation fights it): the claim stores the full request - (`sourceUri`, snapshot, attachments), the T3 context (`projectId`, - `baseRef`), and pre-minted planned IDs (`threadId`, `userMessageId`, - branch name) so a cold-start sweep can redo provisioning without the - original webhook and can detect an already-created thread instead of - duplicating it. -- [x] 4b. Reply delivery — **decided 2026-08-16**: identity and content are - separate. The adapter must answer with **certainty** whether its reply - for this exact exchange exists on the platform, via structural - attribution (Discord reply referencing the trigger message, Jira comment - linkage), or an embedded exchange UUID as last resort — never content - matching, since identical texts legitimately recur. The verbatim payload - persisted in `ReplyPending` is only the content, so retries post the - same thing. Recovery: check existence → post if absent → record posted. -- [x] 4c. Failure path — **decided 2026-08-16**: states track delivery, not - outcome quality. Any permanent failure (provisioning, turn, lost thread) - becomes a failure-typed reply through the normal pipe after bounded - attempts; delivering it ends the exchange in `ReplyPosted`, a completed - job from the processor's view. `Undeliverable` (renamed from - `Abandoned`) is the only other terminal state, entered solely from - `ReplyPending` when posting itself is given up: the stored verbatim - reply plus the cause, never retried again. Every exchange ends - `ReplyPosted` or `Undeliverable`. -- [x] 4d. Acknowledgement — **decided 2026-08-16**: the processor has no - business knowing whether the ack succeeded; no exchange state waits on - it. The adapter records the ack message ID locally and may deliver the - final reply by editing that ack instead of posting fresh — a rendering - choice it owns. An adapter doing so must count the edited ack as the - existing reply in its certainty check (4b). Crash before ack ⇒ ack is - simply never posted; the final reply is unaffected. +4. **Jira port** (ntbs-plan step 3) as the first real adapter on the settled contract, replacing the legacy bridge path. From 9620fd73f1e21cdc69179a7b4c930781c283587b Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 17 Aug 2026 10:10:35 +0200 Subject: [PATCH 081/110] chore: bump ntbs todos --- docs/planning/ntbs-todos.md | 252 +++++++++++++++++++++++++++++------- 1 file changed, 203 insertions(+), 49 deletions(-) diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index cbe689a9223b..c9b69aa8b903 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -16,41 +16,23 @@ RequestClaimed -> ThreadCreated -> ReplyPending -> ReplyPosted - **`ThreadCreated`** — the planned thread exists; the IDs are confirmed facts. No turn state is stored: turn existence and progress are T3-owned. - **`ReplyPending`** — T3 reached a terminal outcome; the exact reply payload is stored verbatim so every posting attempt sends the same content. - **`ReplyPosted`** — terminal. The platform accepted the reply; its message ID is stored. -- **`Undeliverable`** — terminal, entered only from `ReplyPending`: a - finished reply exists but posting was given up after bounded attempts. - Stores the undelivered payload and the cause. The tombstone keeps dedup - intact and stops the sweep from retrying forever. +- **`Undeliverable`** — terminal, entered only from `ReplyPending`: a finished reply exists but posting was given up after bounded attempts. Stores the undelivered payload and the cause. The tombstone keeps dedup intact and stops the sweep from retrying forever. ## Invariants -- One exchange per `sourceUri`, for its whole life. Duplicate deliveries join - it, never create another; they are pure dedup and trigger no repair. -- Forward-only lifecycle: moving backwards is an error, not a write. Failure - may jump ahead to `ReplyPending`. -- States track delivery, not outcome quality. Answer, failure, or - cancellation is data in the reply payload, never a state. Every exchange - ends in `ReplyPosted` or `Undeliverable`. -- T3 stays authoritative for T3-owned facts — no `TurnStarted` or - `AwaitingOutcome` copies in adapter storage. -- Turn-start idempotency rests on `getTurn` being read-your-writes at - reconcile time; provisioning recovery must treat worktree creation as - reentrant (the branch may already exist from a pre-crash attempt). +- One exchange per `sourceUri`, for its whole life. Duplicate deliveries join it, never create another; they are pure dedup and trigger no repair. +- Forward-only lifecycle: moving backwards is an error, not a write. Failure may jump ahead to `ReplyPending`. +- States track delivery, not outcome quality. Answer, failure, or cancellation is data in the reply payload, never a state. Every exchange ends in `ReplyPosted` or `Undeliverable`. +- T3 stays authoritative for T3-owned facts — no `TurnStarted` or `AwaitingOutcome` copies in adapter storage. +- Turn-start idempotency rests on `getTurn` being read-your-writes at reconcile time; provisioning recovery must treat worktree creation as reentrant (the branch may already exist from a pre-crash attempt). ## Recovery -The processor owns recovery. One reconciler, three triggers: startup, -relevant T3 events, and a periodic sweep over incomplete exchanges. The sweep -is the guarantee; live events are only the fast path. Platform redelivery is -not a retry mechanism. +The processor owns recovery. One reconciler, three triggers: startup, relevant T3 events, and a periodic sweep over incomplete exchanges. The sweep is the guarantee; live events are only the fast path. Platform redelivery is not a retry mechanism. ## Pure decider -The branching rules are one pure function, -`stored state + retrieved observation (+ attempts) -> next action`, with -companion transition constructors that turn an action's _result_ into the -next stored state. Decision and transition stay separate so no state ever -records an effect that has not happened. Each state names the single -observation to fetch first: +The branching rules are one pure function, `stored state + retrieved observation (+ attempts) -> next action`, with companion transition constructors that turn an action's _result_ into the next stored state. Decision and transition stay separate so no state ever records an effect that has not happened. Each state names the single observation to fetch first: ```text RequestClaimed planned thread exists? Provision | AdvanceToThreadCreated @@ -59,42 +41,214 @@ ReplyPending my reply on platform? no | yes(id) Post | RecordPosted(id) | ReplyPosted / Undeliverable Done ``` -`StartTurn` persists no exchange transition — turn existence is T3's fact. -The bounded-retry give-up rule lives inside the decider so it is testable. -Orchestration proper — scheduling the triggers, fetching observations, -executing effects, the in-process outcome lock, persisting transitions — -stays in the processor. +`StartTurn` persists no exchange transition — turn existence is T3's fact. The bounded-retry give-up rule lives inside the decider so it is testable. Orchestration proper — scheduling the triggers, fetching observations, executing effects, the in-process outcome lock, persisting transitions — stays in the processor. + +### Proposed decider signatures + +The processor already has to inspect the stored state to know which live facts to retrieve, so the decider can be split into one function per state. These functions receive only plain, already-retrieved data: never adapters, repositories, clocks, `Effect`s, or query functions. `fromX` is concise when the functions are members of an `ExchangeDecider`; standalone functions should prefer `decideFromX`, since a bare `fromX` sounds like a state constructor. + +Retry policy may be captured once when constructing the decider. Attempt history is part of its input: + +```ts +type AttemptHistory = { + readonly failedAttempts: number; + readonly lastFailure: E | null; +}; + +type ExchangeRetryPolicy = { + readonly provisionThreadMaxAttempts: number; + readonly startTurnMaxAttempts: number; + readonly postReplyMaxAttempts: number; +}; + +type ProvisionFailure = { + readonly cause: unknown; +}; + +type TurnStartFailure = { + readonly cause: unknown; +}; + +type ReplyPostFailure = { + readonly cause: unknown; +}; +``` + +Each context describes only the observation relevant to that stored state. A +discriminated union avoids supplying retry information on branches that cannot +use it: + +```ts +type RequestClaimedContext = + | { + readonly plannedThread: "present"; + } + | { + readonly plannedThread: "missing"; + readonly provisioning: AttemptHistory; + }; + +type ThreadCreatedContext = + | { + readonly firstTurn: "missing"; + readonly starting: AttemptHistory; + } + | { + readonly firstTurn: "active"; + } + | { + readonly firstTurn: "terminal"; + readonly reply: NTBSResponse; + } + | { + readonly firstTurn: "lost"; + readonly cause: unknown; + }; + +type ReplyPendingContext = + | { + readonly platformReply: "present"; + readonly replySourceUri: string; + } + | { + readonly platformReply: "absent"; + readonly posting: AttemptHistory; + }; +``` + +Decisions are commands interpreted by the processor, not effects performed by +the decider: + +```ts +type RequestClaimedDecision = + | { readonly type: "provision-thread" } + | { readonly type: "record-thread-created" } + | { + readonly type: "record-reply-pending"; + readonly reply: NTBSResponse; + }; + +type ThreadCreatedDecision = + | { readonly type: "start-turn" } + | { readonly type: "wait" } + | { + readonly type: "record-reply-pending"; + readonly reply: NTBSResponse; + }; + +type ReplyPendingDecision = + | { readonly type: "post-reply" } + | { + readonly type: "record-reply-posted"; + readonly replySourceUri: string; + } + | { + readonly type: "record-undeliverable"; + readonly cause: ReplyPostFailure; + }; + +type TerminalDecision = { + readonly type: "done"; +}; +``` + +The state-specific pure API is: + +```ts +type ExchangeDecider = { + readonly fromRequestClaimed: ( + state: RequestClaimed, + context: RequestClaimedContext, + ) => RequestClaimedDecision; + + readonly fromThreadCreated: ( + state: ThreadCreated, + context: ThreadCreatedContext, + ) => ThreadCreatedDecision; + + readonly fromReplyPending: ( + state: ReplyPending, + context: ReplyPendingContext, + ) => ReplyPendingDecision; + + readonly fromReplyPosted: (state: ReplyPosted) => TerminalDecision; + + readonly fromUndeliverable: (state: Undeliverable) => TerminalDecision; +}; + +declare const makeExchangeDecider: (policy: ExchangeRetryPolicy) => ExchangeDecider; +``` + +If a single public entry point is useful for tests or orchestration, it can be +a total dispatcher over correlated state/context pairs: + +```ts +type ExchangeDecisionInput = + | { + readonly state: RequestClaimed; + readonly context: RequestClaimedContext; + } + | { + readonly state: ThreadCreated; + readonly context: ThreadCreatedContext; + } + | { + readonly state: ReplyPending; + readonly context: ReplyPendingContext; + } + | { + readonly state: ReplyPosted; + } + | { + readonly state: Undeliverable; + }; + +type ExchangeDecision = + | RequestClaimedDecision + | ThreadCreatedDecision + | ReplyPendingDecision + | TerminalDecision; + +declare const decideExchange: ( + decider: ExchangeDecider, + input: ExchangeDecisionInput, +) => ExchangeDecision; +``` + +State construction remains a separate concern. This keeps a decision from +claiming that an external effect has already succeeded: + +```ts +declare const toThreadCreated: (state: RequestClaimed) => ThreadCreated; + +declare const toReplyPending: ( + state: RequestClaimed | ThreadCreated, + reply: NTBSResponse, +) => ReplyPending; + +declare const toReplyPosted: (state: ReplyPending, replySourceUri: string) => ReplyPosted; + +declare const toUndeliverable: (state: ReplyPending, cause: ReplyPostFailure) => Undeliverable; +``` + +This signature design exposes one unresolved persistence question: bounded retry cannot reliably use process-local counters. If retry limits must survive restarts, `AttemptHistory` must be stored durably, either in the applicable exchange state or in a durable envelope around it. ## Reply delivery Identity and content are separate: -- **Identity**: the adapter must answer with certainty whether its reply for - this exact exchange exists on the platform — structural attribution - (Discord reply referencing the trigger message, Jira comment linkage) or an - embedded exchange UUID as last resort. Never content matching; identical - texts legitimately recur. -- **Content**: the verbatim payload stored in `ReplyPending`, so retries post - the same thing. +- **Identity**: the adapter must answer with certainty whether its reply for this exact exchange exists on the platform — structural attribution (Discord reply referencing the trigger message, Jira comment linkage) or an embedded exchange UUID as last resort. Never content matching; identical texts legitimately recur. +- **Content**: the verbatim payload stored in `ReplyPending`, so retries post the same thing. Delivery is: check existence -> post if absent -> record posted. ## Failure path -Any permanent failure (provisioning, turn, lost thread) becomes a -failure-typed reply through the normal delivery pipe after bounded attempts; -delivering it ends the exchange in `ReplyPosted` — a completed job from the -processor's view. Only when posting itself is given up does the exchange end -`Undeliverable`. +Any permanent failure (provisioning, turn, lost thread) becomes a failure-typed reply through the normal delivery pipe after bounded attempts; delivering it ends the exchange in `ReplyPosted` — a completed job from the processor's view. Only when posting itself is given up does the exchange end `Undeliverable`. ## Acknowledgement -The processor never learns whether the ack succeeded; no exchange state waits -on it. The adapter records the ack message ID locally and may deliver the -final reply by editing that ack instead of posting fresh — a rendering choice -it owns. An adapter doing so must count the edited ack as the existing reply -in its certainty check. A crash before the ack means it is simply never -posted; the final reply is unaffected. +The processor never learns whether the ack succeeded; no exchange state waits on it. The adapter records the ack message ID locally and may deliver the final reply by editing that ack instead of posting fresh — a rendering choice it owns. An adapter doing so must count the edited ack as the existing reply in its certainty check. A crash before the ack means it is simply never posted; the final reply is unaffected. ## Adapter contract (shape, not API) From 7bb0177f7a7a0ee9a61d8fe9c99cdedfe62f182b Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 17 Aug 2026 22:42:26 +0200 Subject: [PATCH 082/110] feat: continue refactor of exchange --- apps/server/src/ntbs/adapter.ts | 5 - apps/server/src/ntbs/exchange.ts | 163 ++++++++++++++++++++++--- docs/planning/ntbs-todos.md | 196 +++---------------------------- 3 files changed, 164 insertions(+), 200 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 89c97bd8c048..3a5c529d41a6 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -11,11 +11,6 @@ export class AdapterError extends Data.TaggedError("AdapterError")<{ readonly reason: string; }> {} -export type NTBSResponse = { - readonly type: "answer" | "failure" | "cancellation"; - readonly text: string; -}; - /** * Defines the platform-specific operations used by the shared NTBS processor. * diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index 688fbf2b8130..e74aee0e19cf 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -1,4 +1,9 @@ -import type { ChatAttachment, MessageId, ThreadId } from "@t3tools/contracts"; +import type { ChatAttachment, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; + +/* +This file exposes the core model (data type and the business logic) of +`Exchange`s. An Exchange represent a two-way data bla bla. +*/ export type Request = { /** @@ -22,18 +27,27 @@ export type Request = { * Only the adapter that wrote it may parse it; the processor treats * it as an opaque string. */ - sourceUri: string; + readonly sourceUri: string; /** * The captured source text sent as the first T3 user message. * Platform independent. * Must not exceed T3's 120,000-character input limit. */ - snapshot: string; + readonly snapshot: string; /** * References to attachments stored by T3 and sent with the first user message. * The processor creates them from attachment data provided by the adapter. */ - attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; +}; + +export type Reply = { + readonly type: "answer" | "failure" | "cancellation"; + readonly text: string; +}; + +export type UndeliverableCause = { + readonly message: string; }; /** @@ -41,14 +55,19 @@ export type Request = { */ type ExchangeStateBase = Request & { t3: { + projectId: ProjectId; + baseRef: string; + // Planned while RequestClaimed; confirmed by ThreadCreated. + /** The T3 thread created by the lifecycle event */ - threadId: ThreadId; + readonly threadId: ThreadId; /** * The first T3 user message created for this external request. - * This identifies the correct turn and response even if the thread later + * This identifies the correct turn and reply even if the thread later * receives other messages. */ - userMessageId: MessageId; + readonly userMessageId: MessageId; + readonly branchName: string; }; }; @@ -60,7 +79,7 @@ type ExchangeStateBase = Request & { * From here, the processor alone drives the exchange to a terminal state. */ type RequestClaimed = ExchangeStateBase & { - state: "request-claimed"; + tag: "request-claimed"; }; /** @@ -69,7 +88,7 @@ type RequestClaimed = ExchangeStateBase & { * Turn existence and progress are T3-owned. */ type ThreadCreated = ExchangeStateBase & { - state: "thread-created"; + tag: "thread-created"; }; /** @@ -77,8 +96,8 @@ type ThreadCreated = ExchangeStateBase & { * verbatim so every posting attempt sends the same content. */ type ReplyPending = ExchangeStateBase & { - state: "reply-pending"; - reply: string; + tag: "reply-pending"; + reply: Reply; }; /** @@ -86,9 +105,9 @@ type ReplyPending = ExchangeStateBase & { * The platform accepted the reply; its message ID is stored. */ type ReplyPosted = ExchangeStateBase & { - state: "reply-posted"; - reply: string; - replySourceUuri: string; + tag: "reply-posted"; + reply: Reply; + replySourceUri: string; }; /** @@ -100,14 +119,14 @@ type ReplyPosted = ExchangeStateBase & { * The tombstone keeps dedup intact and stops the processor from retrying together. */ type Undeliverable = ExchangeStateBase & { - state: "undeliverable"; - reply: string; - cause: unknown; + tag: "undeliverable"; + reply: Reply; + cause: UndeliverableCause; }; /** * The state of an exchange between an external platform and T3, from thread - * creation through final-response delivery. Adapters store the latest state to + * creation through final-reply delivery. Adapters store the latest state to * track progress and resume incomplete exchanges after a restart. */ export type ExchangeState = @@ -116,3 +135,111 @@ export type ExchangeState = | ReplyPending | ReplyPosted | Undeliverable; + +/* +Decider/Policy pattern. + +Let's compare the command/reducer pattern with the decider/policy one. + +The command/reducer pattern is about applying mechanical and deterministic changes to a state of the program, via a command, to get the new state. + +reducer: (currentState, command) -> state + +The command expresses intent, and the reducer owns state transitions. In the command/reducer pattern we already know what should happen with the state, we only need to define how. + +A different pattern to the previous one is presented by the **policy/decider** pattern. Here, the goal is not to decide the next state of the program, but to answer: given this state, and this context, what should be the next action/command? + +decider: (state, content) -> command + +The decider/policy pattern is important in the NTBS module because we have to frequently ask: +"given this information I have about the exchange and this context (e.g. checking external platforms or t3 thread states) what should we do next?" + +This can be later combined with the reducer pattern again to describe the reconciliation flow: +1. load state effect +2. retrieve observations effect +3. make decision pure +4. execute decision effect +5. persist resulting state effect +*/ + +export type RequestClaimedContext = { readonly thread: "missing" } | { readonly thread: "present" }; + +export type ThreadCreatedContext = + | { + readonly turn: "missing"; + } + | { readonly turn: "active" } + | { readonly turn: "completed"; readonly reply: Reply }; + +export type ReplyPendingContext = + | { + readonly platformReply: "missing"; + } + | { + readonly platformReply: "posted"; + readonly replySourceUri: string; + }; + +export const toThreadCreated = (state: RequestClaimed): ThreadCreated => ({ + ...state, + tag: "thread-created", +}); + +export const toReplyPending = ( + state: RequestClaimed | ThreadCreated, + reply: Reply, +): ReplyPending => ({ + ...state, + tag: "reply-pending", + reply, +}); + +export const toReplyPosted = (state: ReplyPending, replySourceUri: string): ReplyPosted => ({ + ...state, + tag: "reply-posted", + replySourceUri, +}); + +export const toUndeliverable = (state: ReplyPending, cause: UndeliverableCause): Undeliverable => ({ + ...state, + tag: "undeliverable", + cause, +}); + +export type RequestClaimedDecision = + | { readonly type: "provision-thread" } + | { readonly type: "record-thread-created" }; + +export type ThreadCreatedDecision = + | { readonly type: "start-turn" } + | { readonly type: "wait" } + | { + readonly type: "record-reply-pending"; + readonly reply: Reply; + }; + +export type ReplyPendingDecision = + | { readonly type: "post-reply" } + | { + readonly type: "record-reply-posted"; + readonly replySourceUri: string; + }; + +export type FromRequestClaimed = (input: { + readonly state: RequestClaimed; + readonly context: RequestClaimedContext; +}) => RequestClaimedDecision; + +// TODO: Continue from here implementing the business logic + +export const fromRequestClaimed: FromRequestClaimed = (input) => ({}); + +export type FromThreadCreated = (input: { + readonly state: ThreadCreated; + readonly context: ThreadCreatedContext; +}) => ThreadCreatedDecision; + +export type FromReplyPending = (input: { + readonly state: ReplyPending; + readonly context: ReplyPendingContext; +}) => ReplyPendingDecision; diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index c9b69aa8b903..b67b528a0c3f 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -16,7 +16,7 @@ RequestClaimed -> ThreadCreated -> ReplyPending -> ReplyPosted - **`ThreadCreated`** — the planned thread exists; the IDs are confirmed facts. No turn state is stored: turn existence and progress are T3-owned. - **`ReplyPending`** — T3 reached a terminal outcome; the exact reply payload is stored verbatim so every posting attempt sends the same content. - **`ReplyPosted`** — terminal. The platform accepted the reply; its message ID is stored. -- **`Undeliverable`** — terminal, entered only from `ReplyPending`: a finished reply exists but posting was given up after bounded attempts. Stores the undelivered payload and the cause. The tombstone keeps dedup intact and stops the sweep from retrying forever. +- **`Undeliverable`** — terminal, entered only from `ReplyPending`: a finished reply exists but the platform definitively rejected delivery. Stores the undelivered payload and a serializable explanation. The tombstone keeps dedup intact and stops the sweep from retrying forever. ## Invariants @@ -32,206 +32,48 @@ The processor owns recovery. One reconciler, three triggers: startup, relevant T ## Pure decider -The branching rules are one pure function, `stored state + retrieved observation (+ attempts) -> next action`, with companion transition constructors that turn an action's _result_ into the next stored state. Decision and transition stay separate so no state ever records an effect that has not happened. Each state names the single observation to fetch first: +The branching rules are pure: `stored state + retrieved observation -> next action`. Decision and transition stay separate so no state records an external effect before it happens. The processor retrieves the one observation relevant to the current state and interprets the returned action: ```text -RequestClaimed planned thread exists? Provision | AdvanceToThreadCreated -ThreadCreated turn missing | active | terminal(r) StartTurn | Wait | StoreReply(r) -ReplyPending my reply on platform? no | yes(id) Post | RecordPosted(id) | GiveUp(cause) +RequestClaimed planned thread missing | present Provision | RecordThreadCreated +ThreadCreated turn missing | active | completed(r) StartTurn | Wait | RecordReplyPending(r) +ReplyPending my reply missing | posted(id) PostReply | RecordReplyPosted(id) ReplyPosted / Undeliverable Done ``` -`StartTurn` persists no exchange transition — turn existence is T3's fact. The bounded-retry give-up rule lives inside the decider so it is testable. Orchestration proper — scheduling the triggers, fetching observations, executing effects, the in-process outcome lock, persisting transitions — stays in the processor. - -### Proposed decider signatures - -The processor already has to inspect the stored state to know which live facts to retrieve, so the decider can be split into one function per state. These functions receive only plain, already-retrieved data: never adapters, repositories, clocks, `Effect`s, or query functions. `fromX` is concise when the functions are members of an `ExchangeDecider`; standalone functions should prefer `decideFromX`, since a bare `fromX` sounds like a state constructor. - -Retry policy may be captured once when constructing the decider. Attempt history is part of its input: +The state-specific contexts contain only plain, already-retrieved data—never adapters, repositories, clocks, `Effect`s, or query functions: ```ts -type AttemptHistory = { - readonly failedAttempts: number; - readonly lastFailure: E | null; -}; - -type ExchangeRetryPolicy = { - readonly provisionThreadMaxAttempts: number; - readonly startTurnMaxAttempts: number; - readonly postReplyMaxAttempts: number; -}; - -type ProvisionFailure = { - readonly cause: unknown; -}; - -type TurnStartFailure = { - readonly cause: unknown; -}; - -type ReplyPostFailure = { - readonly cause: unknown; -}; -``` - -Each context describes only the observation relevant to that stored state. A -discriminated union avoids supplying retry information on branches that cannot -use it: - -```ts -type RequestClaimedContext = - | { - readonly plannedThread: "present"; - } - | { - readonly plannedThread: "missing"; - readonly provisioning: AttemptHistory; - }; +type RequestClaimedContext = { readonly thread: "missing" } | { readonly thread: "present" }; type ThreadCreatedContext = - | { - readonly firstTurn: "missing"; - readonly starting: AttemptHistory; - } - | { - readonly firstTurn: "active"; - } - | { - readonly firstTurn: "terminal"; - readonly reply: NTBSResponse; - } - | { - readonly firstTurn: "lost"; - readonly cause: unknown; - }; + | { readonly turn: "missing" } + | { readonly turn: "active" } + | { readonly turn: "completed"; readonly reply: Reply }; type ReplyPendingContext = + | { readonly platformReply: "missing" } | { - readonly platformReply: "present"; + readonly platformReply: "posted"; readonly replySourceUri: string; - } - | { - readonly platformReply: "absent"; - readonly posting: AttemptHistory; }; ``` -Decisions are commands interpreted by the processor, not effects performed by -the decider: - -```ts -type RequestClaimedDecision = - | { readonly type: "provision-thread" } - | { readonly type: "record-thread-created" } - | { - readonly type: "record-reply-pending"; - readonly reply: NTBSResponse; - }; - -type ThreadCreatedDecision = - | { readonly type: "start-turn" } - | { readonly type: "wait" } - | { - readonly type: "record-reply-pending"; - readonly reply: NTBSResponse; - }; - -type ReplyPendingDecision = - | { readonly type: "post-reply" } - | { - readonly type: "record-reply-posted"; - readonly replySourceUri: string; - } - | { - readonly type: "record-undeliverable"; - readonly cause: ReplyPostFailure; - }; - -type TerminalDecision = { - readonly type: "done"; -}; -``` - -The state-specific pure API is: - -```ts -type ExchangeDecider = { - readonly fromRequestClaimed: ( - state: RequestClaimed, - context: RequestClaimedContext, - ) => RequestClaimedDecision; - - readonly fromThreadCreated: ( - state: ThreadCreated, - context: ThreadCreatedContext, - ) => ThreadCreatedDecision; - - readonly fromReplyPending: ( - state: ReplyPending, - context: ReplyPendingContext, - ) => ReplyPendingDecision; - - readonly fromReplyPosted: (state: ReplyPosted) => TerminalDecision; - - readonly fromUndeliverable: (state: Undeliverable) => TerminalDecision; -}; - -declare const makeExchangeDecider: (policy: ExchangeRetryPolicy) => ExchangeDecider; -``` - -If a single public entry point is useful for tests or orchestration, it can be -a total dispatcher over correlated state/context pairs: - -```ts -type ExchangeDecisionInput = - | { - readonly state: RequestClaimed; - readonly context: RequestClaimedContext; - } - | { - readonly state: ThreadCreated; - readonly context: ThreadCreatedContext; - } - | { - readonly state: ReplyPending; - readonly context: ReplyPendingContext; - } - | { - readonly state: ReplyPosted; - } - | { - readonly state: Undeliverable; - }; - -type ExchangeDecision = - | RequestClaimedDecision - | ThreadCreatedDecision - | ReplyPendingDecision - | TerminalDecision; - -declare const decideExchange: ( - decider: ExchangeDecider, - input: ExchangeDecisionInput, -) => ExchangeDecision; -``` +Transient operational failures do not enter this model: the processor leaves the current state unchanged and the periodic sweep tries again. A definitive provisioning or turn-start failure becomes a failure-typed `ReplyPending`; a definitive platform delivery rejection becomes `Undeliverable`. Those classifications belong to the impure operation boundary, not the decider context. -State construction remains a separate concern. This keeps a decision from -claiming that an external effect has already succeeded: +Pure transition constructors preserve the forward-only lifecycle: ```ts declare const toThreadCreated: (state: RequestClaimed) => ThreadCreated; -declare const toReplyPending: ( - state: RequestClaimed | ThreadCreated, - reply: NTBSResponse, -) => ReplyPending; +declare const toReplyPending: (state: RequestClaimed | ThreadCreated, reply: Reply) => ReplyPending; declare const toReplyPosted: (state: ReplyPending, replySourceUri: string) => ReplyPosted; -declare const toUndeliverable: (state: ReplyPending, cause: ReplyPostFailure) => Undeliverable; +declare const toUndeliverable: (state: ReplyPending, cause: UndeliverableCause) => Undeliverable; ``` -This signature design exposes one unresolved persistence question: bounded retry cannot reliably use process-local counters. If retry limits must survive restarts, `AttemptHistory` must be stored durably, either in the applicable exchange state or in a durable envelope around it. +`StartTurn` persists no exchange transition—turn existence is T3's fact. Orchestration proper—scheduling triggers, fetching observations, executing actions, classifying operational failures, applying transitions, and persisting them—stays in the processor. ## Reply delivery @@ -244,7 +86,7 @@ Delivery is: check existence -> post if absent -> record posted. ## Failure path -Any permanent failure (provisioning, turn, lost thread) becomes a failure-typed reply through the normal delivery pipe after bounded attempts; delivering it ends the exchange in `ReplyPosted` — a completed job from the processor's view. Only when posting itself is given up does the exchange end `Undeliverable`. +Any definitive failure while provisioning, starting a turn, or recovering a lost thread becomes a failure-typed reply through the normal delivery pipe; delivering it ends the exchange in `ReplyPosted`—a completed job from the processor's view. Transient failures leave the current state unchanged for the sweep to retry. Only a definitive rejection of reply delivery ends the exchange in `Undeliverable`. ## Acknowledgement @@ -264,7 +106,7 @@ decided during the build. Model → contract → orchestration; each phase leaves the previous one settled. -1. **Exchange (the model).** The five states with their decided contents. Transition constructors as the only way to build each state from its predecessor plus an effect result. The observation and action vocabularies, and the pure decider with its give-up rule. Pure table tests for decider and transitions — no Effect scaffolding. +1. **Exchange (the model).** The five states with their decided contents. Transition constructors as the only way to build each state from its predecessor plus an effect result. The observation and action vocabularies, and the pure decider. Pure table tests for decider and transitions—no Effect scaffolding. 2. **Adapter (the contract).** Reshape the interface around the model per "Adapter contract" above. Update the in-memory test adapter. From 5d6115215f098c8ac461a026488c6310cb940500 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 18 Aug 2026 20:25:27 +0200 Subject: [PATCH 083/110] feat: implement exchange.ts --- apps/server/src/ntbs/exchange.ts | 147 ++++++++++++++++++------------- 1 file changed, 84 insertions(+), 63 deletions(-) diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index e74aee0e19cf..82b47354b4b8 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -1,8 +1,8 @@ import type { ChatAttachment, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; /* -This file exposes the core model (data type and the business logic) of -`Exchange`s. An Exchange represent a two-way data bla bla. +This file defines the durable state and pure business rules for an exchange: +one admitted external request, its T3 work, and delivery of the eventual reply back to the originating platform. */ export type Request = { @@ -51,15 +51,14 @@ export type UndeliverableCause = { }; /** - * The base data type common to all member of ExchangeState + * The base data type common to all members of ExchangeState */ type ExchangeStateBase = Request & { - t3: { - projectId: ProjectId; - baseRef: string; + readonly t3: { + readonly projectId: ProjectId; + readonly baseRef: string; // Planned while RequestClaimed; confirmed by ThreadCreated. - /** The T3 thread created by the lifecycle event */ readonly threadId: ThreadId; /** * The first T3 user message created for this external request. @@ -78,56 +77,54 @@ type ExchangeStateBase = Request & { * * From here, the processor alone drives the exchange to a terminal state. */ -type RequestClaimed = ExchangeStateBase & { - tag: "request-claimed"; +export type RequestClaimed = ExchangeStateBase & { + readonly tag: "request-claimed"; }; /** - * T3 has created the new thread and the adapter has recorded its relationship - * to the platform request. The first turn may not have started yet. - * Turn existence and progress are T3-owned. + * The planned T3 thread exists. The first turn may not have started yet. Turn existence and progress are T3-owned. */ -type ThreadCreated = ExchangeStateBase & { - tag: "thread-created"; +export type ThreadCreated = ExchangeStateBase & { + readonly tag: "thread-created"; }; /** * T3 reached a terminal outcome; the exact reply payload is stored * verbatim so every posting attempt sends the same content. + * This state may also follow `RequestClaimed` directly after a definitive provisioning failure, so it and later states do not imply the thread existed. + * Reply delivery needs only `sourceUri`. */ -type ReplyPending = ExchangeStateBase & { - tag: "reply-pending"; - reply: Reply; +export type ReplyPending = ExchangeStateBase & { + readonly tag: "reply-pending"; + readonly reply: Reply; }; /** * Terminal state. * The platform accepted the reply; its message ID is stored. */ -type ReplyPosted = ExchangeStateBase & { - tag: "reply-posted"; - reply: Reply; - replySourceUri: string; +export type ReplyPosted = ExchangeStateBase & { + readonly tag: "reply-posted"; + readonly reply: Reply; + readonly replySourceUri: string; }; /** * Terminal state. - * A finished reply exists but posting was given up after bounded attempts. + * A finished reply exists, but the platform definitively rejected delivery. * Stores the undelivered payload and the cause. * Common causes could be: the original discussion or message has been deleted * or locked (Jira/Github issue, Discord thread), the bot has been kicked, etc. - * The tombstone keeps dedup intact and stops the processor from retrying together. + * The tombstone keeps dedup intact and stops the processor from retrying forever. */ -type Undeliverable = ExchangeStateBase & { - tag: "undeliverable"; - reply: Reply; - cause: UndeliverableCause; +export type Undeliverable = ExchangeStateBase & { + readonly tag: "undeliverable"; + readonly reply: Reply; + readonly cause: UndeliverableCause; }; /** - * The state of an exchange between an external platform and T3, from thread - * creation through final-reply delivery. Adapters store the latest state to - * track progress and resume incomplete exchanges after a restart. + * The state of an exchange between an external platform and T3, from request claim through final-reply delivery. Adapters store the latest state to track progress and resume incomplete exchanges after a restart. */ export type ExchangeState = | RequestClaimed @@ -136,30 +133,32 @@ export type ExchangeState = | ReplyPosted | Undeliverable; +export const makeRequestClaimed = (input: Omit): RequestClaimed => ({ + ...input, + tag: "request-claimed", +}); + /* Decider/Policy pattern. -Let's compare the command/reducer pattern with the decider/policy one. - -The command/reducer pattern is about applying mechanical and deterministic changes to a state of the program, via a command, to get the new state. - -reducer: (currentState, command) -> state - -The command expresses intent, and the reducer owns state transitions. In the command/reducer pattern we already know what should happen with the state, we only need to define how. - -A different pattern to the previous one is presented by the **policy/decider** pattern. Here, the goal is not to decide the next state of the program, but to answer: given this state, and this context, what should be the next action/command? +The decider answers: given the stored state and the relevant live context, +what should happen next? -decider: (state, content) -> command +decider: (state, context) -> action The decider/policy pattern is important in the NTBS module because we have to frequently ask: "given this information I have about the exchange and this context (e.g. checking external platforms or t3 thread states) what should we do next?" -This can be later combined with the reducer pattern again to describe the reconciliation flow: -1. load state effect -2. retrieve observations effect -3. make decision pure -4. execute decision effect -5. persist resulting state effect +A decision does not transition the exchange. The processor first executes the +chosen effect; only after it succeeds does the processor construct the legal +transition, passing along any result data the effect produced. +The reconciliation flow is: + +1. load state effect +2. retrieve observations effect +3. make decision pure +4. execute decision effect +5. construct the transition from its result pure, then persist as an effect */ export type RequestClaimedContext = { readonly thread: "missing" } | { readonly thread: "present" }; @@ -225,21 +224,43 @@ export type ReplyPendingDecision = readonly replySourceUri: string; }; -export type FromRequestClaimed = (input: { - readonly state: RequestClaimed; - readonly context: RequestClaimedContext; -}) => RequestClaimedDecision; - -// TODO: Continue from here implementing the business logic - -export const fromRequestClaimed: FromRequestClaimed = (input) => ({}); - -export type FromThreadCreated = (input: { - readonly state: ThreadCreated; - readonly context: ThreadCreatedContext; -}) => ThreadCreatedDecision; +export const fromRequestClaimed = ( + _state: RequestClaimed, + context: RequestClaimedContext, +): RequestClaimedDecision => + context.thread === "missing" ? { type: "provision-thread" } : { type: "record-thread-created" }; + +export const fromThreadCreated = ( + _state: ThreadCreated, + context: ThreadCreatedContext, +): ThreadCreatedDecision => { + switch (context.turn) { + case "missing": + return { type: "start-turn" }; + + case "active": + return { type: "wait" }; + + case "completed": + return { + type: "record-reply-pending", + reply: context.reply, + }; + } +}; -export type FromReplyPending = (input: { - readonly state: ReplyPending; - readonly context: ReplyPendingContext; -}) => ReplyPendingDecision; +export const fromReplyPending = ( + _state: ReplyPending, + context: ReplyPendingContext, +): ReplyPendingDecision => { + switch (context.platformReply) { + case "missing": + return { type: "post-reply" }; + + case "posted": + return { + type: "record-reply-posted", + replySourceUri: context.replySourceUri, + }; + } +}; From 3feccf6c0b5f85ee2fa2708d722502ad3a5ca768 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 18 Aug 2026 23:11:30 +0200 Subject: [PATCH 084/110] feat: complete exchange refactor and its tests --- apps/server/src/ntbs/exchange.test.ts | 135 ++++++++++++++++++++++++++ apps/server/src/ntbs/exchange.ts | 3 +- 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/ntbs/exchange.test.ts diff --git a/apps/server/src/ntbs/exchange.test.ts b/apps/server/src/ntbs/exchange.test.ts new file mode 100644 index 000000000000..8525eb22474f --- /dev/null +++ b/apps/server/src/ntbs/exchange.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + fromReplyPending, + fromRequestClaimed, + fromThreadCreated, + makeRequestClaimed, + toReplyPending, + toReplyPosted, + toThreadCreated, + toUndeliverable, + type ExchangeStateBase, + type ReplyPosted, + type RequestClaimed, +} from "./exchange.ts"; +import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; + +const exchangeStateBase = { + sourceUri: "test://exchange/test", + snapshot: "You need to imagine some text here", + attachments: [], + t3: { + projectId: ProjectId.make("projectId"), + baseRef: "baseRef", + threadId: ThreadId.make("threadId"), + userMessageId: MessageId.make("messageId"), + branchName: "branchName", + }, +} satisfies ExchangeStateBase; + +describe("RequestClaimed", () => { + const claimed = makeRequestClaimed(exchangeStateBase); + + it("makeRequestClaimed tags the base unchanged", () => { + expect(claimed).toEqual({ ...exchangeStateBase, tag: "request-claimed" }); + }); + + // if thread is missing we provision the thread + // if its present we record it's been created + it.each([ + [{ thread: "missing" }, { type: "provision-thread" }], + [{ thread: "present" }, { type: "record-thread-created" }], + ] as const)("decides %j -> %j", (context, expected) => { + expect(fromRequestClaimed(claimed, context)).toEqual(expected); + }); + + it("toThreadCreated retags and carries every claim field forward", () => { + expect(toThreadCreated(claimed)).toEqual({ ...exchangeStateBase, tag: "thread-created" }); + }); + + it("provisioning failure jumps ahead with the reply stored verbatim", () => { + const reply = { type: "failure", text: "provisioning rejected" } as const; + expect(toReplyPending(claimed, reply)).toEqual({ + ...exchangeStateBase, + tag: "reply-pending", + reply, + }); + }); +}); + +describe("ThreadCreated", () => { + const threadCreated = toThreadCreated(makeRequestClaimed(exchangeStateBase)); + const answer = { type: "answer", text: "The turn's final answer" } as const; + + // missing turn -> start it; active turn -> wait; completed turn -> record its reply + it.each([ + [{ turn: "missing" }, { type: "start-turn" }], + [{ turn: "active" }, { type: "wait" }], + [ + { turn: "completed", reply: answer }, + { type: "record-reply-pending", reply: answer }, + ], + ] as const)("decides %j -> %j", (context, expected) => { + expect(fromThreadCreated(threadCreated, context)).toEqual(expected); + }); + + it("completed turn's reply lands in ReplyPending verbatim", () => { + expect(toReplyPending(threadCreated, answer)).toEqual({ + ...exchangeStateBase, + tag: "reply-pending", + reply: answer, + }); + }); +}); + +describe("ReplyPending", () => { + const reply = { type: "answer", text: "The turn's final answer" } as const; + const replyPending = toReplyPending( + toThreadCreated(makeRequestClaimed(exchangeStateBase)), + reply, + ); + + // missing platform reply -> post it; posted -> record its message id + it.each([ + [{ platformReply: "missing" }, { type: "post-reply" }], + [ + { platformReply: "posted", replySourceUri: "test://exchange/reply" }, + { type: "record-reply-posted", replySourceUri: "test://exchange/reply" }, + ], + ] as const)("decides %j -> %j", (context, expected) => { + expect(fromReplyPending(replyPending, context)).toEqual(expected); + }); + + it("accepted delivery lands in ReplyPosted with the platform message id", () => { + expect(toReplyPosted(replyPending, "test://exchange/reply")).toEqual({ + ...exchangeStateBase, + tag: "reply-posted", + reply, + replySourceUri: "test://exchange/reply", + }); + }); + + it("definitive rejection lands in Undeliverable with the reply and cause", () => { + const cause = { message: "original message was deleted" } as const; + expect(toUndeliverable(replyPending, cause)).toEqual({ + ...exchangeStateBase, + tag: "undeliverable", + reply, + cause, + }); + }); +}); + +/* +Type-level: forward-only is structural. Never executed — typecheck enforces +these. If an `@ts-expect-error` stops erroring, a constructor's input type +widened and the forward-only guarantee broke. +*/ +const _forwardOnly = (posted: ReplyPosted, claimed: RequestClaimed) => { + // @ts-expect-error terminal states cannot re-enter thread creation + toThreadCreated(posted); + // @ts-expect-error a bare claim cannot record a posted reply + toReplyPosted(claimed, "reply://msg"); + // @ts-expect-error Undeliverable is entered only from ReplyPending + toUndeliverable(claimed, { message: "cause" }); +}; diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index 82b47354b4b8..fabe3ff91d89 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -53,12 +53,11 @@ export type UndeliverableCause = { /** * The base data type common to all members of ExchangeState */ -type ExchangeStateBase = Request & { +export type ExchangeStateBase = Request & { readonly t3: { readonly projectId: ProjectId; readonly baseRef: string; // Planned while RequestClaimed; confirmed by ThreadCreated. - readonly threadId: ThreadId; /** * The first T3 user message created for this external request. From 83945a3f02395fdb5fafdd89238db9740cc96d34 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 19 Aug 2026 12:43:15 +0200 Subject: [PATCH 085/110] feat: update exchange.ts, implement exchangerepository --- apps/server/src/ntbs/ExchangeRepository.ts | 39 ++++++++++++++++++++ apps/server/src/ntbs/adapter.ts | 11 +++--- apps/server/src/ntbs/exchange.ts | 42 ++++++++++++++++------ 3 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/ntbs/ExchangeRepository.ts diff --git a/apps/server/src/ntbs/ExchangeRepository.ts b/apps/server/src/ntbs/ExchangeRepository.ts new file mode 100644 index 000000000000..93b28ef04279 --- /dev/null +++ b/apps/server/src/ntbs/ExchangeRepository.ts @@ -0,0 +1,39 @@ +/* + * Defines the repository for durable NTBS exchange state. + * + * An exchange links an admitted external-platform request to its planned T3 + * work and tracks its progress through delivery of the eventual reply. + * + * The repository owns persistence, lookup, and recovery. Each stored exchange + * is identified by its `sourceUri`, while the processor decides how to handle + * duplicate requests. It does not communicate with T3 or the originating + * platform. + */ +import { type Effect, Context, Data } from "effect"; +import { type ExchangeState, type NonTerminalExchangeState } from "./exchange.ts"; +import type { ThreadId } from "@t3tools/contracts"; + +export class ExchangeRepositoryError extends Data.TaggedError("ExchangeRepositoryError")<{ + readonly reason: string; + readonly cause: unknown; +}> {} + +export interface ExchangeRepository { + readonly findBySourceUri: ( + sourceUri: string, + ) => Effect.Effect; + + readonly findByThreadId: ( + threadId: ThreadId, + ) => Effect.Effect; + + readonly findNonTerminalExchanges: Effect.Effect< + ReadonlyArray, + ExchangeRepositoryError + >; + + /** Inserts or replaces the exchange identified by its `sourceUri`. */ + readonly upsert: (state: ExchangeState) => Effect.Effect; +} + +export const makeRepositoryTag = (key: string) => Context.Service(key); diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 3a5c529d41a6..7457335399f4 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -43,9 +43,9 @@ export interface NTBSAdapter { * Returns the platform's idenitifier for the posted message. * The processor uses that identifier to save `ResponsePosted`. */ - readonly postResponse: ( + readonly postReply: ( state: NTBS.ThreadCreated, - response: NTBSResponse, + reply: NTBS.Reply, ) => Effect.Effect; /** @@ -55,9 +55,10 @@ export interface NTBSAdapter { * may continue. * Any lifecycle state means the request already has a T3 thread. */ - readonly findByRequest: ( - request: NTBS.Request, - ) => Effect.Effect; + // TODO: Remove? + // readonly findByRequest: ( + // request: NTBS.Request, + // ) => Effect.Effect; /** * Searches the response destination for a matching response previously * posted by this adapter. diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index fabe3ff91d89..c29df89b7a10 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -9,15 +9,15 @@ export type Request = { /** * Adapter-encoded URI locating the originating platform message, * e.g. `discord:////` or - * `jira:///comment/`. + * `jira:///issue//comment/`. * * Two contracts: * * Identity — the same platform request must carry the same string * across redeliveries and restarts; distinct requests must carry - * distinct strings. This is the durable dedup key `findByRequest` + * distinct strings. This is the durable dedup key `findBySourceUri` * looks up, the key the processor serializes concurrent deliveries - * on, and the natural unique key for the adapter's stored records. + * on, and the natural unique key for the repository's stored records. * * Addressability — it must contain everything needed to reach the * message through the platform API from a cold start, because @@ -122,15 +122,37 @@ export type Undeliverable = ExchangeStateBase & { readonly cause: UndeliverableCause; }; +/** States for exchanges that still have work left to do. */ +export type NonTerminalExchangeState = RequestClaimed | ThreadCreated | ReplyPending; + +/** States for exchanges that have finished, with the reply either posted or undeliverable. */ +export type TerminalExchangeState = ReplyPosted | Undeliverable; + /** - * The state of an exchange between an external platform and T3, from request claim through final-reply delivery. Adapters store the latest state to track progress and resume incomplete exchanges after a restart. + * The state of an exchange between an external platform and T3, from request + * claim through final-reply delivery. The exchange repository stores the latest + * state to track progress and resume non-terminal exchanges after a restart. */ -export type ExchangeState = - | RequestClaimed - | ThreadCreated - | ReplyPending - | ReplyPosted - | Undeliverable; +export type ExchangeState = NonTerminalExchangeState | TerminalExchangeState; + +export const isTerminalState = (state: ExchangeState): state is TerminalExchangeState => { + // An exhaustive switch makes new lifecycle states require an explicit classification. + // This way it is impossible to break the program semantics by adding a new state + // and forgetting to deal with it, because it would not typecheck. + switch (state.tag) { + case "reply-posted": + case "undeliverable": + return true; + + case "request-claimed": + case "thread-created": + case "reply-pending": + return false; + } +}; + +export const isNonTerminalState = (state: ExchangeState): state is NonTerminalExchangeState => + !isTerminalState(state); export const makeRequestClaimed = (input: Omit): RequestClaimed => ({ ...input, From 60ec43f0fa3daaf4a073f38cdb3793e0391ab70b Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 21 Aug 2026 16:23:29 +0200 Subject: [PATCH 086/110] feat: update adapter and exchangerepository --- .../src/ntbs/ExchangeRepository.test.ts | 163 ++++++++++++++++++ apps/server/src/ntbs/ExchangeRepository.ts | 82 ++++++++- apps/server/src/ntbs/adapter.ts | 33 ---- 3 files changed, 242 insertions(+), 36 deletions(-) create mode 100644 apps/server/src/ntbs/ExchangeRepository.test.ts diff --git a/apps/server/src/ntbs/ExchangeRepository.test.ts b/apps/server/src/ntbs/ExchangeRepository.test.ts new file mode 100644 index 000000000000..915a4193aaa2 --- /dev/null +++ b/apps/server/src/ntbs/ExchangeRepository.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; +import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { + ExchangeRepositoryError, + ExchangeRepositoryTag, + inMemoryExchangeRepository, +} from "./ExchangeRepository.ts"; +import { + makeRequestClaimed, + toReplyPending, + toReplyPosted, + toThreadCreated, + toUndeliverable, +} from "./exchange.ts"; + +const makeExchange = (sourceUri: string, threadId: string) => + makeRequestClaimed({ + sourceUri, + snapshot: "request", + attachments: [], + t3: { + projectId: ProjectId.make("project"), + baseRef: "main", + threadId: ThreadId.make(threadId), + userMessageId: MessageId.make(`message-${threadId}`), + branchName: `branch-${threadId}`, + }, + }); + +describe("inMemoryExchangeRepository", () => { + it.layer(inMemoryExchangeRepository)((it) => { + it.effect("allows the same sourceUri to replace its state", () => + Effect.gen(function* () { + const repository = yield* ExchangeRepositoryTag; + const claimed = makeExchange("test://request/1", "thread-1"); + const threadCreated = toThreadCreated(claimed); + + yield* repository.upsert(claimed); + yield* repository.upsert(threadCreated); + + expect(yield* repository.findBySourceUri(claimed.sourceUri)).toEqual(threadCreated); + }), + ); + }); + + it.layer(inMemoryExchangeRepository)((it) => { + it.effect("rejects a threadId already owned by another sourceUri", () => + Effect.gen(function* () { + const repository = yield* ExchangeRepositoryTag; + const existing = makeExchange("test://request/1", "shared-thread"); + const conflicting = makeExchange("test://request/2", "shared-thread"); + + yield* repository.upsert(existing); + const error = yield* Effect.flip(repository.upsert(conflicting)); + + expect(error).toBeInstanceOf(ExchangeRepositoryError); + expect(error.reason).toContain(existing.t3.threadId); + expect(yield* repository.findBySourceUri(existing.sourceUri)).toEqual(existing); + expect(yield* repository.findBySourceUri(conflicting.sourceUri)).toBeNull(); + }), + ); + }); + + it.layer(inMemoryExchangeRepository)((it) => { + it.effect("finds an exchange by threadId", () => + Effect.gen(function* () { + const repository = yield* ExchangeRepositoryTag; + const exchange = makeExchange("test://request/1", "thread-1"); + + yield* repository.upsert(exchange); + + expect(yield* repository.findByThreadId(exchange.t3.threadId)).toEqual(exchange); + expect(yield* repository.findByThreadId(ThreadId.make("unknown-thread"))).toBeNull(); + }), + ); + }); + + it.layer(inMemoryExchangeRepository)((it) => { + it.effect("finds only non-terminal exchanges", () => + Effect.gen(function* () { + const repository = yield* ExchangeRepositoryTag; + const claimed = makeExchange("test://request/claimed", "thread-claimed"); + const threadCreated = toThreadCreated( + makeExchange("test://request/thread-created", "thread-created"), + ); + const replyPending = toReplyPending( + toThreadCreated(makeExchange("test://request/reply-pending", "thread-reply-pending")), + { type: "answer", text: "pending reply" }, + ); + const replyPosted = toReplyPosted( + toReplyPending( + toThreadCreated(makeExchange("test://request/reply-posted", "thread-reply-posted")), + { type: "answer", text: "posted reply" }, + ), + "test://reply/posted", + ); + const undeliverable = toUndeliverable( + toReplyPending( + toThreadCreated(makeExchange("test://request/undeliverable", "thread-undeliverable")), + { type: "failure", text: "undeliverable reply" }, + ), + { message: "platform rejected the reply" }, + ); + + yield* Effect.forEach( + [claimed, threadCreated, replyPending, replyPosted, undeliverable], + repository.upsert, + ); + + const results = yield* repository.findNonTerminalExchanges; + + expect(results).toHaveLength(3); + expect(results).toEqual(expect.arrayContaining([claimed, threadCreated, replyPending])); + }), + ); + }); + + it.layer(inMemoryExchangeRepository)((it) => { + it.effect("preserves existing records when a replacement has a conflicting threadId", () => + Effect.gen(function* () { + const repository = yield* ExchangeRepositoryTag; + const first = makeExchange("test://request/1", "thread-1"); + const second = makeExchange("test://request/2", "thread-2"); + const conflictingReplacement = makeExchange("test://request/2", "thread-1"); + + yield* repository.upsert(first); + yield* repository.upsert(second); + yield* Effect.flip(repository.upsert(conflictingReplacement)); + + expect(yield* repository.findBySourceUri(first.sourceUri)).toEqual(first); + expect(yield* repository.findBySourceUri(second.sourceUri)).toEqual(second); + expect(yield* repository.findByThreadId(first.t3.threadId)).toEqual(first); + expect(yield* repository.findByThreadId(second.t3.threadId)).toEqual(second); + }), + ); + }); + + it.layer(inMemoryExchangeRepository)((it) => { + it.effect("atomically rejects concurrent upserts with the same threadId", () => + Effect.gen(function* () { + const repository = yield* ExchangeRepositoryTag; + const first = makeExchange("test://request/1", "shared-thread"); + const second = makeExchange("test://request/2", "shared-thread"); + + const outcomes = yield* Effect.all( + [Effect.exit(repository.upsert(first)), Effect.exit(repository.upsert(second))], + { concurrency: "unbounded" }, + ); + + expect(outcomes.filter(Exit.isSuccess)).toHaveLength(1); + expect(outcomes.filter(Exit.isFailure)).toHaveLength(1); + + const stored = yield* Effect.all([ + repository.findBySourceUri(first.sourceUri), + repository.findBySourceUri(second.sourceUri), + ]); + + expect(stored.filter((state) => state !== null)).toHaveLength(1); + }), + ); + }); +}); diff --git a/apps/server/src/ntbs/ExchangeRepository.ts b/apps/server/src/ntbs/ExchangeRepository.ts index 93b28ef04279..a475fe64fefd 100644 --- a/apps/server/src/ntbs/ExchangeRepository.ts +++ b/apps/server/src/ntbs/ExchangeRepository.ts @@ -9,9 +9,14 @@ * duplicate requests. It does not communicate with T3 or the originating * platform. */ -import { type Effect, Context, Data } from "effect"; -import { type ExchangeState, type NonTerminalExchangeState } from "./exchange.ts"; +import { Array, Effect, Context, Data, HashMap, Ref, Layer } from "effect"; +import { + isNonTerminalState, + type ExchangeState, + type NonTerminalExchangeState, +} from "./exchange.ts"; import type { ThreadId } from "@t3tools/contracts"; +import { isSome } from "effect/Option"; export class ExchangeRepositoryError extends Data.TaggedError("ExchangeRepositoryError")<{ readonly reason: string; @@ -36,4 +41,75 @@ export interface ExchangeRepository { readonly upsert: (state: ExchangeState) => Effect.Effect; } -export const makeRepositoryTag = (key: string) => Context.Service(key); +export const ExchangeRepositoryTag = Context.Service( + "t3code/ntbs/ExchangeRepository", +); + +const inMemoryER: Effect.Effect = Effect.gen(function* () { + const exchanges: Ref.Ref> = yield* Ref.make( + HashMap.empty(), + ); + + const upsert = Effect.fn("ExchangeRepository.upsert")(function* (state: ExchangeState) { + // we return conflicting source Uri as the first argument + // in case we find that the same threadId belongs already to a different sourceUri + const conflictingSourceUri = yield* Ref.modify(exchanges, (map) => { + const conflict = HashMap.findFirst( + map, + (existing, sourceUri) => + sourceUri !== state.sourceUri && existing.t3.threadId === state.t3.threadId, + ); + + return isSome(conflict) + ? [conflict.value[0], map] + : [null, HashMap.set(map, state.sourceUri, state)]; + }); + + if (conflictingSourceUri !== null) { + return yield* new ExchangeRepositoryError({ + reason: `Thread ${state.t3.threadId} already belongs to exchange ${conflictingSourceUri}`, + cause: { + threadId: state.t3.threadId, + existingSourceUri: conflictingSourceUri, + incomingSourceUri: state.sourceUri, + }, + }); + } + }); + + const findBySourceUri = (uri: string) => + Ref.get(exchanges).pipe( + Effect.map((map) => HashMap.get(map, uri)), + Effect.map((o) => (isSome(o) ? o.value : null)), + ); + + const findByThreadId = (threadId: ThreadId) => + Ref.get(exchanges).pipe( + Effect.map((map) => HashMap.filter(map, (val) => val.t3.threadId === threadId)), + // if we get more than one ExchangeState in the HashMap, something's wrong + Effect.andThen((map) => + HashMap.size(map) > 1 + ? new ExchangeRepositoryError({ + reason: "Exchange Repository contains more than one entry for thredId: " + threadId, + cause: map, + }) + : Effect.succeed(Array.fromIterable(HashMap.entries(map))).pipe( + Effect.map((arr) => (arr.length === 1 ? arr[0]![1] : null)), + ), + ), + ); + + const findNonTerminalExchanges = Ref.get(exchanges).pipe( + Effect.map((map) => Array.fromIterable(HashMap.entries(map))), + Effect.map((arr) => + Array.filter( + arr.map((el) => el[1]), + isNonTerminalState, + ), + ), + ); + + return { upsert, findBySourceUri, findByThreadId, findNonTerminalExchanges }; +}); + +export const inMemoryExchangeRepository = Layer.effect(ExchangeRepositoryTag, inMemoryER); diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 7457335399f4..19a26641dad0 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -1,4 +1,3 @@ -import type { ThreadId } from "@t3tools/contracts"; import * as NTBS from "./exchange.ts"; import { Context, Data, Effect } from "effect"; @@ -25,10 +24,6 @@ export class AdapterError extends Data.TaggedError("AdapterError")<{ * It does not create T3 threads or interpret T3 events. */ export interface NTBSAdapter { - /** - * Stores a lifecycle state. Does not perform any other business logic. - */ - readonly save: (lifecycleEvent: NTBS.ExchangeState) => Effect.Effect; /** * Posts the working acknowledgement at the response destination, * described by the event. @@ -48,17 +43,6 @@ export interface NTBSAdapter { reply: NTBS.Reply, ) => Effect.Effect; - /** - * Finds lifecycle data already recorded for this platform request. - * - * Returns `null` when no T3 thread has been recorded and processing - * may continue. - * Any lifecycle state means the request already has a T3 thread. - */ - // TODO: Remove? - // readonly findByRequest: ( - // request: NTBS.Request, - // ) => Effect.Effect; /** * Searches the response destination for a matching response previously * posted by this adapter. @@ -69,23 +53,6 @@ export interface NTBSAdapter { readonly findMatchingResponseMessage: ( state: NTBS.ThreadCreated, ) => Effect.Effect; - /** - * Finds the latest lifecycle state associated with a T3 thread. - * - * Fails with `ThreadNotFound` when this adapter has no request associated - * with that thread. - */ - readonly findByThreadId: ( - threadId: ThreadId, - ) => Effect.Effect; - /** - * Loads records that reached `ThreadCreated` but have no recorded - * `ResponsePosted` state. - */ - readonly loadThreadsAwaitingResponse: Effect.Effect< - ReadonlyArray, - AdapterError - >; } export const makeNTBSAdapterTag = (key: string) => Context.Service(key); From a8ae9fde8871383d54c4a0a604749b98274ee441 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Fri, 21 Aug 2026 16:36:21 +0200 Subject: [PATCH 087/110] feat: update adapter --- apps/server/src/ntbs/adapter.ts | 61 +++++++++++++++------------------ 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 19a26641dad0..5d23cbe0caa8 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -1,58 +1,53 @@ -import * as NTBS from "./exchange.ts"; +import type { ReplyPending, ThreadCreated, UndeliverableCause } from "./exchange.ts"; import { Context, Data, Effect } from "effect"; -export class ThreadNotFound extends Data.TaggedError("ThreadNotFound") {} - /** - * Generic error catcher, will be refined later + * A platform operation failed without establishing that reply delivery is + * permanently impossible. The processor may retry the operation later. */ export class AdapterError extends Data.TaggedError("AdapterError")<{ readonly reason: string; + readonly cause: unknown; +}> {} + +/** The platform definitively rejected delivery of a pending reply. */ +export class ReplyRejected extends Data.TaggedError("ReplyRejected")<{ + readonly cause: UndeliverableCause; }> {} /** * Defines the platform-specific operations used by the shared NTBS processor. * - * The adapter stores lifecycle data, finds that data from a T3 thread ID, and - * posts acknowledgements and responses. - * - * The adapter owns its storage and retention policy. A stored snapshot may - * outlive the original platform message. E.g. a message on Discord gets deleted - * but its still persisted in the original snapshot. - * Verify retention policies. - * - * It does not create T3 threads or interpret T3 events. + * An adapter communicates with one originating platform. It posts + * acknowledgements and replies, and can discover whether a particular pending + * reply was already posted. It does not persist exchange state, create T3 + * threads, or interpret T3 events. */ export interface NTBSAdapter { /** - * Posts the working acknowledgement at the response destination, - * described by the event. - * - * Returns the platform's identifier for the posted message. + * Posts a best-effort working acknowledgement for an exchange whose T3 + * thread now exists. The acknowledgement is not part of the durable exchange + * lifecycle and its platform identifier is not retained. */ - readonly acknowledge: (state: NTBS.ThreadCreated) => Effect.Effect; + readonly acknowledge: (state: ThreadCreated) => Effect.Effect; + /** - * Posts the final T3 outcome at the response destination described - * by the event. + * Posts the exact reply stored in `state` to the destination identified by + * its `sourceUri`. * - * Returns the platform's idenitifier for the posted message. - * The processor uses that identifier to save `ResponsePosted`. + * Returns an adapter-encoded URI locating the posted reply. `ReplyRejected` + * means the platform definitively refused delivery; other failures remain + * retryable. */ - readonly postReply: ( - state: NTBS.ThreadCreated, - reply: NTBS.Reply, - ) => Effect.Effect; + readonly postReply: (state: ReplyPending) => Effect.Effect; /** - * Searches the response destination for a matching response previously - * posted by this adapter. + * Searches for the exact pending reply in case it was posted before the + * corresponding `ReplyPosted` state could be persisted. * - * Returns the platform message ID when found, or `null` when no matching - * message exists. + * Returns its adapter-encoded source URI when found, or `null` otherwise. */ - readonly findMatchingResponseMessage: ( - state: NTBS.ThreadCreated, - ) => Effect.Effect; + readonly findPostedReply: (state: ReplyPending) => Effect.Effect; } export const makeNTBSAdapterTag = (key: string) => Context.Service(key); From 3a298d0d3cb16d0309f65d760edcb149852c8484 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 12:12:08 +0200 Subject: [PATCH 088/110] feat: t3gateway core type implementation --- .../src/ntbs/ExchangeRepository.test.ts | 2 +- apps/server/src/ntbs/exchange.ts | 19 ++- apps/server/src/ntbs/processor.test.ts | 2 +- apps/server/src/ntbs/processor2.test.ts | 2 +- apps/server/src/ntbs/t3gateway.ts | 117 ++++++++++++++++++ apps/server/src/ntbs/test-helpers.ts | 2 +- 6 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/ntbs/t3gateway.ts diff --git a/apps/server/src/ntbs/ExchangeRepository.test.ts b/apps/server/src/ntbs/ExchangeRepository.test.ts index 915a4193aaa2..24a2a4a9b86f 100644 --- a/apps/server/src/ntbs/ExchangeRepository.test.ts +++ b/apps/server/src/ntbs/ExchangeRepository.test.ts @@ -98,7 +98,7 @@ describe("inMemoryExchangeRepository", () => { const undeliverable = toUndeliverable( toReplyPending( toThreadCreated(makeExchange("test://request/undeliverable", "thread-undeliverable")), - { type: "failure", text: "undeliverable reply" }, + { type: "failure", text: "undeliverable reply", cause: "undeliverable te dico" }, ), { message: "platform rejected the reply" }, ); diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index c29df89b7a10..e63ff5e57a40 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -41,11 +41,26 @@ export type Request = { readonly attachments: ReadonlyArray; }; -export type Reply = { - readonly type: "answer" | "failure" | "cancellation"; +export type ReplyFailure = { + readonly type: "failure"; readonly text: string; + readonly cause: unknown; }; +export type ReplyCancellation = { + readonly type: "cancellation"; + readonly text: string; + readonly cause: unknown; +}; + +export type Reply = + | { + readonly type: "answer"; + readonly text: string; + } + | ReplyFailure + | ReplyCancellation; + export type UndeliverableCause = { readonly message: string; }; diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index 649abb322bab..3f7dcf5bfc7a 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -9,7 +9,7 @@ import { } from "@t3tools/contracts"; import { DateTime, Deferred, Effect, Layer, PubSub, Stream } from "effect"; import { makeNTBSAdapterTag, ThreadNotFound, type NTBSAdapter } from "./adapter.ts"; -import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; +import { makeNTBSProcessor, makeNTBSProcessorTag } from "./t3gateway.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts index f9e5d227e81e..41fa1806c5cd 100644 --- a/apps/server/src/ntbs/processor2.test.ts +++ b/apps/server/src/ntbs/processor2.test.ts @@ -17,7 +17,7 @@ import { type NTBSResponse, } from "./adapter.ts"; import type { Request, ExchangeState, ThreadCreated } from "./exchange.ts"; -import { makeNTBSProcessor, makeNTBSProcessorTag } from "./processor.ts"; +import { makeNTBSProcessor, makeNTBSProcessorTag } from "./t3gateway.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; diff --git a/apps/server/src/ntbs/t3gateway.ts b/apps/server/src/ntbs/t3gateway.ts new file mode 100644 index 000000000000..5d22d3ccfd8d --- /dev/null +++ b/apps/server/src/ntbs/t3gateway.ts @@ -0,0 +1,117 @@ +/* +The T3 gateway module exposes the interface that the NTBS processor uses to communicate +with T3, similar to how adapter models the interaction with the external platform. + */ + +import { + type ChatAttachment, + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + OrchestrationCommand, + type OrchestrationEvent, + type ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import type * as NTBS from "./exchange.ts"; +import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; +import type { NTBSAdapter } from "./adapter.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; +import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import type { ExchangeStateBase } from "./exchange.ts"; + +/* + NTBS architecture: + + 1. Adapter + Responsible for the communication with the external platform (Jira, Discord, Teams, etc). + - `acknowledge` confirms T3 is processing the user request + - `postReply` sends the reply to the platform + - `findPostedReplies` retries the replies sent to the platform (but maybe not recorded due to crash) + + 2. ExchangeRepository + Responsible for saving `Exchange` data, entities that model the incoming message -> reply cycle and the relations to T3 data (threads, messages, turns). + + 3. T3 gateway + Models the interaction with T3's own api and VCS lifecycle: creating threads, worktrees, starting turns, etc. + + 4. NTBS Processor + The orchestrator between 1, 2, 3 and 4. + + TODO: Better description of the whole architecture. +*/ + +export class T3GatewayError extends Data.TaggedError("T3GatewayError")<{ + reason: string; + cause: unknown; +}> {} + +type T3GatewayRequirements = + /* + Dispatches thread creation and turn-start commands. + Provides the T3 event stream used to detect outcomes. + */ + | OrchestrationEngineService + /* + Loads the selected T3 project and reads thread outcomes. + */ + | ProjectionSnapshotQuery + /* + Finds the exact projected turn associated with the original T3 user message. + */ + | ProjectionTurnRepository + /* + Creates the isolated branch and worktree for each external request. + */ + | GitWorkflowService + /* + Runs the project setup scripts in the newly created worktree before agent work begins. + */ + | ProjectSetupScriptRunner + /* + Generates unique identifiers for the new thread, message, commands, and worktree branch. + */ + | Crypto.Crypto; + +/** T3 will never accept this work; the processor records a failure reply. */ +export class T3Rejected extends Data.TaggedError("T3Rejected")<{ + reason: string; + cause: unknown; +}> {} + +export interface T3Gateway { + /** Mints the planned thread, message and branch identifiers recorded at claim. */ + readonly planT3Work: (input: { + readonly projectId: ProjectId; + readonly baseRef: string; + }) => Effect.Effect; + + readonly getThreadStatus: ( + state: NTBS.RequestClaimed, + ) => Effect.Effect; + + /** Reentrant: worktree, thread creation and setup scripts, each skipped if already done. */ + readonly provisionThread: ( + state: NTBS.RequestClaimed, + ) => Effect.Effect; + + /** Reports turn progress, interpreting a finished turn into a verbatim `Reply`. */ + readonly getTurnStatus: ( + state: NTBS.ThreadCreated, + ) => Effect.Effect; + + readonly startTurn: ( + state: NTBS.ThreadCreated, + ) => Effect.Effect; + + /** Threads whose T3 state just changed; the processor reconciles each. */ + readonly threadActivity: Stream.Stream; +} + +const t3GatewayTag = Context.Service("t3code/ntbs/t3Gateway"); diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts index 58617e385a4e..9567ec092f45 100644 --- a/apps/server/src/ntbs/test-helpers.ts +++ b/apps/server/src/ntbs/test-helpers.ts @@ -8,7 +8,7 @@ import { VcsCreateWorktreeResult, } from "@t3tools/contracts"; import type { Request, ExchangeState, ThreadCreated } from "./exchange.ts"; -import type { T3Context } from "./processor.ts"; +import type { T3Context } from "./t3gateway.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { makeNTBSAdapterTag, ThreadNotFound, type NTBSResponse } from "./adapter.ts"; From 4f64b36a46191fc4640d6cfd0829409a254af5ec Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 14:19:44 +0200 Subject: [PATCH 089/110] chore: update ntbs docs --- docs/planning/ntbs-todos.md | 44 +++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index b67b528a0c3f..f07185b7656b 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -4,6 +4,18 @@ The old model stored only `ThreadCreated | ResponsePosted` through a generic `save`, so the processor had to re-derive "what already happened" from adapter storage, T3 projections, process-local locks, and the external platform on every step. The settled design replaces it with a claimed, forward-only exchange machine with one reconciler. +## Ports + +Three ports, one per thing the exchange has to touch. Each knows only what it wraps, and none of them knows about the others. + +- **Exchange repository** — the durable record of where each exchange got to. Stores, looks up by `sourceUri` or `threadId`, and lists the incomplete ones for startup recovery. Nothing in it reaches T3 or the platform. +- **Adapter** — the originating platform. Posts the acknowledgement and the reply, and answers with certainty whether its reply for this exchange is already there. The only piece that may parse a `sourceUri`. +- **T3 gateway** — T3 and the VCS lifecycle behind it. Plans the identifiers, provisions the thread and worktree, starts the turn, reports thread and turn status, and signals which threads have moved. + +The processor sits above them and holds the orchestration none of them have: it reads the state, asks the relevant port what is true now, decides, executes, and records the transition. It is the only writer of exchange state, and the only place the three ports meet. + +No leases and no multi-process machinery anywhere: the real deployment is one server process. How each port enforces the invariants is implementation, decided during the build. + ## States ```text @@ -16,19 +28,27 @@ RequestClaimed -> ThreadCreated -> ReplyPending -> ReplyPosted - **`ThreadCreated`** — the planned thread exists; the IDs are confirmed facts. No turn state is stored: turn existence and progress are T3-owned. - **`ReplyPending`** — T3 reached a terminal outcome; the exact reply payload is stored verbatim so every posting attempt sends the same content. - **`ReplyPosted`** — terminal. The platform accepted the reply; its message ID is stored. -- **`Undeliverable`** — terminal, entered only from `ReplyPending`: a finished reply exists but the platform definitively rejected delivery. Stores the undelivered payload and a serializable explanation. The tombstone keeps dedup intact and stops the sweep from retrying forever. +- **`Undeliverable`** — terminal, entered only from `ReplyPending`: a finished reply exists but the platform definitively rejected delivery. Stores the undelivered payload and a serializable explanation. The tombstone keeps dedup intact and stops the processor from retrying forever. ## Invariants - One exchange per `sourceUri`, for its whole life. Duplicate deliveries join it, never create another; they are pure dedup and trigger no repair. - Forward-only lifecycle: moving backwards is an error, not a write. Failure may jump ahead to `ReplyPending`. - States track delivery, not outcome quality. Answer, failure, or cancellation is data in the reply payload, never a state. Every exchange ends in `ReplyPosted` or `Undeliverable`. -- T3 stays authoritative for T3-owned facts — no `TurnStarted` or `AwaitingOutcome` copies in adapter storage. +- T3 stays authoritative for T3-owned facts — no `TurnStarted` or `AwaitingOutcome` copies in the stored exchange. - Turn-start idempotency rests on `getTurn` being read-your-writes at reconcile time; provisioning recovery must treat worktree creation as reentrant (the branch may already exist from a pre-crash attempt). ## Recovery -The processor owns recovery. One reconciler, three triggers: startup, relevant T3 events, and a periodic sweep over incomplete exchanges. The sweep is the guarantee; live events are only the fast path. Platform redelivery is not a retry mechanism. +An exchange can be cut in half by the server stopping: a thread was provisioned, but a turn never started, a reply was computed but not posted, etc. + +The stored state say how far did the exchange go, so work can be easily resumed. + +On startup the processor loads every incomplete exchange and continues it. While running, T3 events tell it when a turn has finished so the reply can be posted. + +An optional, additional recovery method can be envisioned by a periodic function checking whether any non-terminal exchange hung and can be resumed. Any of the non-terminal states can strand: provisioning that keeps failing, a turn that was never started, a turn T3 still calls active but that will never finish (the agent died, hit its token limit, the provider hung), a reply whose posting keeps being rejected. None of these produce an event, so nothing wakes the exchange up. + +It first requires proper invariants and heuristics to be defined — chiefly, how long a state may legitimately sit before it counts as stuck, which differs per state and is not knowable from the exchange alone. ## Pure decider @@ -59,7 +79,7 @@ type ReplyPendingContext = }; ``` -Transient operational failures do not enter this model: the processor leaves the current state unchanged and the periodic sweep tries again. A definitive provisioning or turn-start failure becomes a failure-typed `ReplyPending`; a definitive platform delivery rejection becomes `Undeliverable`. Those classifications belong to the impure operation boundary, not the decider context. +Temporary failures do not change the exchange state, so the processor can try the same operation again later. If creating the thread or starting the turn has permanently failed, the processor creates a failure reply and moves the exchange to `ReplyPending`. If the platform permanently refuses to post that reply, the processor moves the exchange to `Undeliverable`. The processor decides whether an error is temporary or permanent when the operation fails; the decider only sees the plain result it needs. Pure transition constructors preserve the forward-only lifecycle: @@ -86,30 +106,20 @@ Delivery is: check existence -> post if absent -> record posted. ## Failure path -Any definitive failure while provisioning, starting a turn, or recovering a lost thread becomes a failure-typed reply through the normal delivery pipe; delivering it ends the exchange in `ReplyPosted`—a completed job from the processor's view. Transient failures leave the current state unchanged for the sweep to retry. Only a definitive rejection of reply delivery ends the exchange in `Undeliverable`. +Any definitive failure while provisioning, starting a turn, or recovering a lost thread becomes a failure-typed reply through the normal delivery pipe; delivering it ends the exchange in `ReplyPosted`—a completed job from the processor's view. Transient failures leave the current state unchanged and are retried in place. Only a definitive rejection of reply delivery ends the exchange in `Undeliverable`. ## Acknowledgement The processor never learns whether the ack succeeded; no exchange state waits on it. The adapter records the ack message ID locally and may deliver the final reply by editing that ack instead of posting fresh — a rendering choice it owns. An adapter doing so must count the edited ack as the existing reply in its certainty check. A crash before the ack means it is simply never posted; the final reply is unaffected. -## Adapter contract (shape, not API) - -Operations the contract must express: claim (duplicates join the existing -exchange), persist-transition (forward-only), load-incomplete for the sweep, -the reply-existence certainty check, post-reply, and fire-and-forget ack. -Dependencies point at the platform client and storage only — never at the -processor. No leases, no multi-process machinery: the real deployment is one -server process. How an adapter enforces the invariants is implementation, -decided during the build. - ## Build order Model → contract → orchestration; each phase leaves the previous one settled. 1. **Exchange (the model).** The five states with their decided contents. Transition constructors as the only way to build each state from its predecessor plus an effect result. The observation and action vocabularies, and the pure decider. Pure table tests for decider and transitions—no Effect scaffolding. -2. **Adapter (the contract).** Reshape the interface around the model per "Adapter contract" above. Update the in-memory test adapter. +2. **Ports (the contracts).** The exchange repository, the adapter and the T3 gateway, each shaped around the model per "Ports" above. Update the in-memory test adapter. -3. **Processor (orchestration).** Collapse `process` / `recoverThread` / `processT3Event` into one loop: load → fetch the state's observation → decide → execute → persist. Admission becomes claim-then-reconcile. Add the periodic sweep as the third trigger beside startup and T3 events. Keep the outcome lock; review whether `inFlightRequests` still earns its place. Crash-window tests drive the real loop against the in-memory adapter. +3. **Processor (orchestration).** Collapse `process` / `recoverThread` / `processT3Event` into `process` plus one internal reconciler; the exposed surface stays `process` and `run`. `process` claims, then provisions and starts the turn. `run` calls the reconciler — load → observe → decide → execute → persist — on startup and on T3 events. Serialize per `sourceUri`; the outcome lock and `inFlightRequests` collapse into that. Crash-window tests drive the real loop against the real in-memory repository, with the adapter and T3 gateway faked. 4. **Jira port** (ntbs-plan step 3) as the first real adapter on the settled contract, replacing the legacy bridge path. From 0240582894ea4cb65ddd09166110557f1a46d443 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 14:50:30 +0200 Subject: [PATCH 090/110] chore: bump --- apps/server/src/ntbs/processor.ts | 68 ++++++++++++++++--------------- apps/server/src/ntbs/t3gateway.ts | 18 +------- 2 files changed, 37 insertions(+), 49 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 1003cbf538df..e060af099630 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -10,7 +10,7 @@ import { } from "@t3tools/contracts"; import type * as NTBS from "./exchange.ts"; import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; -import type { NTBSAdapter, NTBSResponse } from "./adapter.ts"; +import type { NTBSAdapter } from "./adapter.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; @@ -21,31 +21,31 @@ import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; /* - NTBS architecture: - - 1. Generic NTBS processor: - - Runs the shared workflow for every platform. - - Creates a fresh worktree and T3 thread. - - Saves `ThreadCreated`. - - Starts the first turn with the snapshot and attachments. - - Attempts to post the acknowledgement independently. - - Watches T3 events for completed work. - - Posts the final result through the adapter and saves `ResponsePosted`. - - 2. Platform-specific inbound code: - - Receives raw platform data from Jira, Discord, GitHub, or Teams. - - Applies platform trigger and actor checks. - - Builds `Request` and `t3` context. - - Calls the processor. - - 3. Adapter - - Owns platform storage and platform API calls. - - Posts acknowledgements and responses. - - Knows how platform identifiers are represented. - - Knows nothing about creating T3 threads or interpreting T3 events. +The processor is the executor and orchestrator of non-turn-based surfaces: it applies the business rules and connects T3 to the external platform. It does so through three services: + +- the adapter: communication with the external platform +- the T3 gateway: communication and dispatching of T3 internals +- the exchange repository: durable link between the two, stores the exchange state + +It exposes two public APIs: +1. `process` takes an incoming message and starts the work for it. +2. `run` subscribes to T3 activity and resumes the exchanges a previous run left unfinished. + +Both drive an exchange through the same cycle, repeated until it reaches a terminal state: + +load the stored state +-> read live context from the service that owns it +-> decide what to do given state and context +-> execute the decision +-> build the resulting state transition and persist it + +The cycle is replay safe: it observes before acting, so a crash or a redelivered message re-runs it without starting a second thread or posting a second reply. */ -export type T3Context = { +/** + * Describes _where_ the T3 works goes. Not part of the incoming request. + */ +export type T3Target = { readonly projectId: ProjectId; /** * The starting point for the thread's worktree: the new branch is created @@ -68,24 +68,26 @@ export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ export interface NTBSProcessor { /** - * Processes a request received by a platform adapter. + * Admits one external request, claims it, and drives it to a started T3 turn. * - * The request must already have passed its platform-specific trigger and actor - * checks. The processor does not perform those. + * Returns once the turn is running, not once the request is answered: the + * reply is posted later, when T3 reports the turn finished. * - * Accepts concurrent requests and applies no queue, concurrency cap - * or backpressure for the time being. This choice can be reviewed later. + * Idempotent per `sourceUri`: a redelivery of an already-claimed request is a + * no-op, whatever state that exchange has reached. Concurrent deliveries of + * the same request are serialized, so only the first claims it. */ readonly process: ( request: NTBS.Request, - t3Context: T3Context, + t3Target: T3Target, ) => Effect.Effect; /** - * The main loop of the processor, consumes T3 events and passes them to `processT3Event`. + * The main loop of the processor. * - * After the live subscription begins, loads stored `ThreadCreated` records. - * It starts a missing first turn, or posts the outcome of a turn that already finished. + * Subscribes to T3 activity first, then resumes every exchange left incomplete + * by a previous run: each is continued from the state it reached. From then on + * a T3 thread moving is what wakes its exchange up. * * Runs until interrupted by its caller. * Logs individual processing failures and continues with later events. diff --git a/apps/server/src/ntbs/t3gateway.ts b/apps/server/src/ntbs/t3gateway.ts index 5d22d3ccfd8d..4b462a6667cb 100644 --- a/apps/server/src/ntbs/t3gateway.ts +++ b/apps/server/src/ntbs/t3gateway.ts @@ -3,28 +3,14 @@ The T3 gateway module exposes the interface that the NTBS processor uses to comm with T3, similar to how adapter models the interaction with the external platform. */ -import { - type ChatAttachment, - CommandId, - DEFAULT_PROVIDER_INTERACTION_MODE, - MessageId, - OrchestrationCommand, - type OrchestrationEvent, - type ProjectId, - ThreadId, -} from "@t3tools/contracts"; +import { type ProjectId, ThreadId } from "@t3tools/contracts"; import type * as NTBS from "./exchange.ts"; -import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; -import type { NTBSAdapter } from "./adapter.ts"; +import { Context, Crypto, Data, Effect, Stream } from "effect"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; -import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; -import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; -import type { ExchangeStateBase } from "./exchange.ts"; /* NTBS architecture: From b383ec8b0b59a220535d812cf4b2a0be0bd5266f Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 15:15:00 +0200 Subject: [PATCH 091/110] bump --- apps/server/src/ntbs/processor.ts | 56 +++++++++---------------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index e060af099630..112602f82ba5 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -11,6 +11,8 @@ import { import type * as NTBS from "./exchange.ts"; import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; import type { NTBSAdapter } from "./adapter.ts"; +import type { T3Gateway } from "./t3gateway.ts"; +import type { ExchangeRepository } from "./ExchangeRepository.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; @@ -43,18 +45,14 @@ The cycle is replay safe: it observes before acting, so a crash or a redelivered */ /** - * Describes _where_ the T3 works goes. Not part of the incoming request. + * Describes _where_ the T3 works goes. Necessary for creating worktrees, threads and starting turns. */ export type T3Target = { readonly projectId: ProjectId; /** - * The starting point for the thread's worktree: the new branch is created - * from this ref. + * The starting point for the thread's worktree: the new branch is created from this ref. * - * Usually a branch name such as `main`. Before use it is resolved against - * `origin`, so the worktree starts from the latest remote commit even when - * the local copy of the branch is behind. A commit SHA is also accepted and - * is used as-is. + * Usually a branch name such as `main`. Before use it is resolved against `origin`, so the worktree starts from the latest remote commit even when the local copy of the branch is behind. A commit SHA is also accepted and is used as-is. * * Set by the platform-specific inbound code. */ @@ -68,14 +66,13 @@ export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ export interface NTBSProcessor { /** - * Admits one external request, claims it, and drives it to a started T3 turn. + * Claims one external request and drives its exchange. * - * Returns once the turn is running, not once the request is answered: the - * reply is posted later, when T3 reports the turn finished. + * Does no filtering: the caller decides whether a request deserves T3 work, and everything passed here starts it. * - * Idempotent per `sourceUri`: a redelivery of an already-claimed request is a - * no-op, whatever state that exchange has reached. Concurrent deliveries of - * the same request are serialized, so only the first claims it. + * Returns once the exchange is claimed and under way, not once the request is answered: the reply is posted later, when T3 reports the turn finished. + * + * Idempotent per `sourceUri`: a redelivery of an already-claimed request is a no-op, whatever state that exchange has reached. Concurrent deliveries of the same request are serialized, so only the first claims it. */ readonly process: ( request: NTBS.Request, @@ -84,13 +81,9 @@ export interface NTBSProcessor { /** * The main loop of the processor. + * Subscribes to T3 activity, then resumes every non-terminal exchange. Subscribing first means nothing is missed while recovery runs. After that, an exchange only moves when its T3 thread does. * - * Subscribes to T3 activity first, then resumes every exchange left incomplete - * by a previous run: each is continued from the state it reached. From then on - * a T3 thread moving is what wakes its exchange up. - * - * Runs until interrupted by its caller. - * Logs individual processing failures and continues with later events. + * Never returns. It has no error channel: a failure on one exchange is logged and the next event is still processed. */ readonly run: Effect.Effect; } @@ -99,30 +92,13 @@ export const makeNTBSProcessorTag = (key: string) => Context.Service Date: Mon, 24 Aug 2026 15:19:57 +0200 Subject: [PATCH 092/110] refactor(ntbs): single adapter tag, rewrite processor docs Replace `makeNTBSAdapterTag` with one `NTBSAdapter` tag naming both the interface and the service, so `makeNTBSProcessor` drops its generic and takes all three services from the requirements channel. Each platform's processor is built with its own adapter provided. Rewrite the processor's file and interface documentation, and point `NTBSProcessorRequirements` at the adapter, T3 gateway and exchange repository. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/ntbs/adapter.ts | 5 ++++- apps/server/src/ntbs/processor.ts | 16 +++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/server/src/ntbs/adapter.ts b/apps/server/src/ntbs/adapter.ts index 5d23cbe0caa8..4ae99913981b 100644 --- a/apps/server/src/ntbs/adapter.ts +++ b/apps/server/src/ntbs/adapter.ts @@ -50,4 +50,7 @@ export interface NTBSAdapter { readonly findPostedReply: (state: ReplyPending) => Effect.Effect; } -export const makeNTBSAdapterTag = (key: string) => Context.Service(key); +/** + * One tag for every platform. A processor resolves its adapter from the context it is built in, so each one is given the implementation for its own platform. + */ +export const NTBSAdapter = Context.Service("t3code/ntbs/adapter"); diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 112602f82ba5..5eccf9d3d61f 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -10,7 +10,7 @@ import { } from "@t3tools/contracts"; import type * as NTBS from "./exchange.ts"; import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; -import type { NTBSAdapter } from "./adapter.ts"; +import { NTBSAdapter } from "./adapter.ts"; import type { T3Gateway } from "./t3gateway.ts"; import type { ExchangeRepository } from "./ExchangeRepository.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; @@ -91,6 +91,10 @@ export interface NTBSProcessor { export const makeNTBSProcessorTag = (key: string) => Context.Service(key); type NTBSProcessorRequirements = + /* + Communicates with the external platform. Which platform is decided by the context the processor is built in. + */ + | NTBSAdapter /* Creates worktrees and threads, starts turns, reports their progress, and provides the stream of T3 thread activity. */ @@ -101,15 +105,13 @@ type NTBSProcessorRequirements = | ExchangeRepository; /** - * Creates an NTBS processor for one adapter. + * Builds a processor for the adapter found in the context. * - * Resolves the required T3 services and returns processor operations with no remaining requirements. + * Build one per platform, each with its own adapter provided. */ -export const makeNTBSProcessor = ( - adapterTag: Context.Service, -): Effect.Effect => +export const makeNTBSProcessor: Effect.Effect = Effect.gen(function* () { - const adapter = yield* adapterTag; + const adapter = yield* NTBSAdapter; const orFail = (reason: string) => Effect.mapError((cause: unknown) => new NTBSProcessorError({ reason, cause })); From ba2f39bfdf787dbd6e6ab210e158eac74640fdf4 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 15:27:54 +0200 Subject: [PATCH 093/110] chore: align tags --- apps/server/src/ntbs/ExchangeRepository.test.ts | 14 +++++++------- apps/server/src/ntbs/ExchangeRepository.ts | 4 ++-- apps/server/src/ntbs/t3gateway.ts | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/server/src/ntbs/ExchangeRepository.test.ts b/apps/server/src/ntbs/ExchangeRepository.test.ts index 24a2a4a9b86f..0b14b4ac263e 100644 --- a/apps/server/src/ntbs/ExchangeRepository.test.ts +++ b/apps/server/src/ntbs/ExchangeRepository.test.ts @@ -3,7 +3,7 @@ import { Effect, Exit } from "effect"; import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; import { ExchangeRepositoryError, - ExchangeRepositoryTag, + ExchangeRepository, inMemoryExchangeRepository, } from "./ExchangeRepository.ts"; import { @@ -32,7 +32,7 @@ describe("inMemoryExchangeRepository", () => { it.layer(inMemoryExchangeRepository)((it) => { it.effect("allows the same sourceUri to replace its state", () => Effect.gen(function* () { - const repository = yield* ExchangeRepositoryTag; + const repository = yield* ExchangeRepository; const claimed = makeExchange("test://request/1", "thread-1"); const threadCreated = toThreadCreated(claimed); @@ -47,7 +47,7 @@ describe("inMemoryExchangeRepository", () => { it.layer(inMemoryExchangeRepository)((it) => { it.effect("rejects a threadId already owned by another sourceUri", () => Effect.gen(function* () { - const repository = yield* ExchangeRepositoryTag; + const repository = yield* ExchangeRepository; const existing = makeExchange("test://request/1", "shared-thread"); const conflicting = makeExchange("test://request/2", "shared-thread"); @@ -65,7 +65,7 @@ describe("inMemoryExchangeRepository", () => { it.layer(inMemoryExchangeRepository)((it) => { it.effect("finds an exchange by threadId", () => Effect.gen(function* () { - const repository = yield* ExchangeRepositoryTag; + const repository = yield* ExchangeRepository; const exchange = makeExchange("test://request/1", "thread-1"); yield* repository.upsert(exchange); @@ -79,7 +79,7 @@ describe("inMemoryExchangeRepository", () => { it.layer(inMemoryExchangeRepository)((it) => { it.effect("finds only non-terminal exchanges", () => Effect.gen(function* () { - const repository = yield* ExchangeRepositoryTag; + const repository = yield* ExchangeRepository; const claimed = makeExchange("test://request/claimed", "thread-claimed"); const threadCreated = toThreadCreated( makeExchange("test://request/thread-created", "thread-created"), @@ -119,7 +119,7 @@ describe("inMemoryExchangeRepository", () => { it.layer(inMemoryExchangeRepository)((it) => { it.effect("preserves existing records when a replacement has a conflicting threadId", () => Effect.gen(function* () { - const repository = yield* ExchangeRepositoryTag; + const repository = yield* ExchangeRepository; const first = makeExchange("test://request/1", "thread-1"); const second = makeExchange("test://request/2", "thread-2"); const conflictingReplacement = makeExchange("test://request/2", "thread-1"); @@ -139,7 +139,7 @@ describe("inMemoryExchangeRepository", () => { it.layer(inMemoryExchangeRepository)((it) => { it.effect("atomically rejects concurrent upserts with the same threadId", () => Effect.gen(function* () { - const repository = yield* ExchangeRepositoryTag; + const repository = yield* ExchangeRepository; const first = makeExchange("test://request/1", "shared-thread"); const second = makeExchange("test://request/2", "shared-thread"); diff --git a/apps/server/src/ntbs/ExchangeRepository.ts b/apps/server/src/ntbs/ExchangeRepository.ts index a475fe64fefd..1dc35f3778cc 100644 --- a/apps/server/src/ntbs/ExchangeRepository.ts +++ b/apps/server/src/ntbs/ExchangeRepository.ts @@ -41,7 +41,7 @@ export interface ExchangeRepository { readonly upsert: (state: ExchangeState) => Effect.Effect; } -export const ExchangeRepositoryTag = Context.Service( +export const ExchangeRepository = Context.Service( "t3code/ntbs/ExchangeRepository", ); @@ -112,4 +112,4 @@ const inMemoryER: Effect.Effect = Effect.gen(function* () { return { upsert, findBySourceUri, findByThreadId, findNonTerminalExchanges }; }); -export const inMemoryExchangeRepository = Layer.effect(ExchangeRepositoryTag, inMemoryER); +export const inMemoryExchangeRepository = Layer.effect(ExchangeRepository, inMemoryER); diff --git a/apps/server/src/ntbs/t3gateway.ts b/apps/server/src/ntbs/t3gateway.ts index 4b462a6667cb..73553a047e54 100644 --- a/apps/server/src/ntbs/t3gateway.ts +++ b/apps/server/src/ntbs/t3gateway.ts @@ -100,4 +100,4 @@ export interface T3Gateway { readonly threadActivity: Stream.Stream; } -const t3GatewayTag = Context.Service("t3code/ntbs/t3Gateway"); +export const T3Gateway = Context.Service("t3code/ntbs/t3Gateway"); From 045cdbd26d0a672186acf7255f5dfb388cd31141 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 15:53:18 +0200 Subject: [PATCH 094/110] chore: refactor the processor --- apps/server/src/ntbs/processor.old.ts | 760 ++++++++++++++++++++++++++ apps/server/src/ntbs/processor.ts | 650 +--------------------- 2 files changed, 766 insertions(+), 644 deletions(-) create mode 100644 apps/server/src/ntbs/processor.old.ts diff --git a/apps/server/src/ntbs/processor.old.ts b/apps/server/src/ntbs/processor.old.ts new file mode 100644 index 000000000000..5eccf9d3d61f --- /dev/null +++ b/apps/server/src/ntbs/processor.old.ts @@ -0,0 +1,760 @@ +import { + type ChatAttachment, + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + OrchestrationCommand, + type OrchestrationEvent, + type ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import type * as NTBS from "./exchange.ts"; +import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; +import { NTBSAdapter } from "./adapter.ts"; +import type { T3Gateway } from "./t3gateway.ts"; +import type { ExchangeRepository } from "./ExchangeRepository.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; +import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; + +/* +The processor is the executor and orchestrator of non-turn-based surfaces: it applies the business rules and connects T3 to the external platform. It does so through three services: + +- the adapter: communication with the external platform +- the T3 gateway: communication and dispatching of T3 internals +- the exchange repository: durable link between the two, stores the exchange state + +It exposes two public APIs: +1. `process` takes an incoming message and starts the work for it. +2. `run` subscribes to T3 activity and resumes the exchanges a previous run left unfinished. + +Both drive an exchange through the same cycle, repeated until it reaches a terminal state: + +load the stored state +-> read live context from the service that owns it +-> decide what to do given state and context +-> execute the decision +-> build the resulting state transition and persist it + +The cycle is replay safe: it observes before acting, so a crash or a redelivered message re-runs it without starting a second thread or posting a second reply. +*/ + +/** + * Describes _where_ the T3 works goes. Necessary for creating worktrees, threads and starting turns. + */ +export type T3Target = { + readonly projectId: ProjectId; + /** + * The starting point for the thread's worktree: the new branch is created from this ref. + * + * Usually a branch name such as `main`. Before use it is resolved against `origin`, so the worktree starts from the latest remote commit even when the local copy of the branch is behind. A commit SHA is also accepted and is used as-is. + * + * Set by the platform-specific inbound code. + */ + readonly baseRef: string; +}; + +export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ + reason: string; + cause: unknown; +}> {} + +export interface NTBSProcessor { + /** + * Claims one external request and drives its exchange. + * + * Does no filtering: the caller decides whether a request deserves T3 work, and everything passed here starts it. + * + * Returns once the exchange is claimed and under way, not once the request is answered: the reply is posted later, when T3 reports the turn finished. + * + * Idempotent per `sourceUri`: a redelivery of an already-claimed request is a no-op, whatever state that exchange has reached. Concurrent deliveries of the same request are serialized, so only the first claims it. + */ + readonly process: ( + request: NTBS.Request, + t3Target: T3Target, + ) => Effect.Effect; + + /** + * The main loop of the processor. + * Subscribes to T3 activity, then resumes every non-terminal exchange. Subscribing first means nothing is missed while recovery runs. After that, an exchange only moves when its T3 thread does. + * + * Never returns. It has no error channel: a failure on one exchange is logged and the next event is still processed. + */ + readonly run: Effect.Effect; +} + +export const makeNTBSProcessorTag = (key: string) => Context.Service(key); + +type NTBSProcessorRequirements = + /* + Communicates with the external platform. Which platform is decided by the context the processor is built in. + */ + | NTBSAdapter + /* + Creates worktrees and threads, starts turns, reports their progress, and provides the stream of T3 thread activity. + */ + | T3Gateway + /* + Stores and loads the exchange state, including the exchanges a previous run left unfinished. + */ + | ExchangeRepository; + +/** + * Builds a processor for the adapter found in the context. + * + * Build one per platform, each with its own adapter provided. + */ +export const makeNTBSProcessor: Effect.Effect = + Effect.gen(function* () { + const adapter = yield* NTBSAdapter; + + const orFail = (reason: string) => + Effect.mapError((cause: unknown) => new NTBSProcessorError({ reason, cause })); + + const crypto = yield* Crypto.Crypto; + const randomUUID = crypto.randomUUIDv4.pipe(orFail("Failed creating a UUID v4")); + + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projectionTurnRepository = yield* ProjectionTurnRepository; + + const gitWorkflowService = yield* GitWorkflowService; + + const getNow = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + /** Keeps each user message's lock until its final response is recorded and no caller uses it. */ + const responseLocks = new Map< + MessageId, + { + readonly semaphore: Semaphore.Semaphore; + callers: number; + responsePosted: boolean; + } + >(); + + const getResponseLock = (userMessageId: MessageId) => { + let lock = responseLocks.get(userMessageId); + if (lock === undefined) { + lock = { + semaphore: Semaphore.makeUnsafe(1), + callers: 0, + responsePosted: false, + }; + responseLocks.set(userMessageId, lock); + } + return lock; + }; + + const markResponsePosted = (userMessageId: MessageId): void => { + const lock = responseLocks.get(userMessageId); + if (lock !== undefined) { + lock.responsePosted = true; + } + }; + + /** + * Prevents the turn started by one user message from producing competing + * final outcomes, such as both a normal response and a timeout. + */ + const ensureUniqueOutcome = ( + userMessageId: MessageId, + effect: Effect.Effect, + ): Effect.Effect => + Effect.suspend(() => { + const lock = getResponseLock(userMessageId); + lock.callers += 1; + + return lock.semaphore.withPermit(effect).pipe( + Effect.ensuring( + Effect.sync(() => { + lock.callers -= 1; + if ( + lock.callers === 0 && + lock.responsePosted && + responseLocks.get(userMessageId) === lock + ) { + responseLocks.delete(userMessageId); + } + }), + ), + ); + }); + + /** + * Starts the first turn in an existing T3 thread. + * + * Uses the user message ID recorded in `ThreadCreated` so the resulting turn + * and response can be matched to the external request. + */ + const startT3Turn = ( + threadId: ThreadId, + userMessageId: MessageId, + snapshot: string, + attachments: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + const commandId = CommandId.make(yield* randomUUID); + const createdAt = yield* getNow; + + yield* orchestrationEngineService + .dispatch( + OrchestrationCommand.make({ + type: "thread.turn.start", + commandId, + threadId, + message: { + messageId: userMessageId, + role: "user", + text: snapshot, + attachments, + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt, + }), + ) + .pipe(orFail("Failed to start the first T3 turn")); + }); + + const getTurn = (threadId: ThreadId, userMessageId: MessageId) => + Effect.gen(function* () { + const turns = yield* projectionTurnRepository + .listByThreadId({ threadId }) + .pipe(orFail(`Failed loading turns for T3 thread ${threadId}`)); + /* + Using `.find` is safe: a userMessageId can never label more than one turn. + The UUID is minted once per request, and a turn start is only repeated + (by recovery) when no turn exists for it. + If the turn started, `.find` finds it. Finding none means the turn never + started (e.g. crash) or T3 discarded it before a provider picked it up. + */ + const turn = turns.find((turn) => turn.pendingMessageId === userMessageId); + return turn ?? null; + }); + + /** + * Reads the final outcome of the turn started by one NTBS user message. + * Returns `null` while that exact turn is still pending or running. + */ + const resolveT3Outcome = ( + threadId: ThreadId, + userMessageId: MessageId, + ): Effect.Effect => + Effect.gen(function* () { + const turn = yield* getTurn(threadId, userMessageId); + + if (!turn) { + return yield* new NTBSProcessorError({ + reason: `Turn for user message ${userMessageId} not found.`, + cause: { threadId, userMessageId }, + }); + } + + if (turn.state === "pending" || turn.state === "running") { + return null; + } + + const maybeThread = yield* projectionSnapshotQuery + .getThreadDetailById(threadId) + .pipe(orFail(`Failed loading T3 thread ${threadId}`)); + + const thread = yield* Effect.fromOption(maybeThread).pipe( + orFail(`Could not find T3 thread ${threadId}`), + ); + + if (turn.state === "completed") { + const assistantMessage = + turn.assistantMessageId === null + ? undefined + : thread.messages.find((message) => message.id === turn.assistantMessageId); + + const text = assistantMessage?.text.trim() ?? ""; + + return text.length > 0 + ? { type: "answer", text } + : { + type: "failure", + text: "T3 completed without producing a response.", + }; + } + + if (turn.state === "error") { + return { + type: "failure", + text: thread.session?.lastError ?? "T3 failed while processing this request.", + }; + } + + return { + type: "cancellation", + text: "T3 stopped processing this request.", + }; + }); + + /** + * Posts one final response and records it in the adapter lifecycle. + * + * The caller must have already confirmed that no response is recorded and + * must hold the outcome lock for this user message. If the platform already + * contains the response, it is recorded instead of reposted. + */ + const postResponse = ( + threadCreated: NTBS.ThreadCreated, + response: NTBSResponse, + ): Effect.Effect => + Effect.gen(function* () { + const existingResponseMessageId = yield* adapter + .findMatchingResponseMessage(threadCreated) + .pipe(orFail("Failed checking whether the NTBS response was already posted")); + + const responseMessageId = + existingResponseMessageId ?? + (yield* adapter + .postResponse(threadCreated, response) + .pipe(orFail("Failed posting the NTBS response"))); + + yield* adapter + .save({ + ...threadCreated, + state: "thread.response.posted", + responseMessageId, + }) + .pipe(orFail("Failed recording the posted NTBS response")); + + markResponsePosted(threadCreated.t3.userMessageId); + }); + + /** + * Posts the final response when a T3 session event ends an NTBS turn. + * Other T3 events and threads unknown to this adapter are ignored. + */ + const processT3Event = (event: OrchestrationEvent): Effect.Effect => + Effect.gen(function* () { + if (event.type !== "thread.session-set") { + return; + } + + const threadId = event.payload.threadId; + + /* + We may receive events for threads that are not related to the current + platform, and thus, adapter. + So we check if the thread in question exists in the adapter records. + */ + const recordedThread = yield* adapter.findByThreadId(threadId).pipe( + Effect.catchTag("ThreadNotFound", () => Effect.succeed(null)), + orFail("Failed loading the NTBS lifecycle for a T3 event"), + ); + + if (recordedThread === null) { + return; + } + + /* + At the same time a thread may have different messages. We're only interested + in the last user message that appears in the adapter records. + */ + const userMessageId = recordedThread.t3.userMessageId; + + yield* ensureUniqueOutcome( + userMessageId, + Effect.gen(function* () { + // Timeout handling may have posted a response while this event was + // waiting for the same user message's outcome lock. + const currentRecord = yield* adapter.findByThreadId(threadId).pipe( + Effect.catchTag("ThreadNotFound", () => Effect.succeed(null)), + orFail("Failed reloading the NTBS lifecycle before posting its outcome"), + ); + + if (currentRecord === null) { + return; + } + + if (currentRecord.state === "thread.response.posted") { + markResponsePosted(userMessageId); + return; + } + + const response = yield* resolveT3Outcome(threadId, userMessageId); + if (response === null) { + return; + } + + yield* postResponse(currentRecord, response); + }), + ); + }); + + /** + * Resolves where a new thread worktree starts from. + * + * Fetches `origin` and prefers the remote state of `baseRef`, so a branch name resolves to its latest remote commit even when the local copy is behind. + * + * When no remote branch with that name exists + * (a commit SHA, a tag, a local-only branch, or no reachable remote), + * the ref is returned as-is for git to resolve during worktree creation. + * + * Never fails: an unresolvable ref surfaces later as a worktree-creation error, + * which carries the real git cause. + * + */ + const resolveWorktreeBase = (input: { + readonly cwd: string; + readonly baseRef: string; + }): Effect.Effect<{ readonly refName: string; readonly baseRefName: string | null }> => + Effect.gen(function* () { + // A failed fetch only means we resolve against the last-known remote state + // The tracking ref may still exist locally + yield* gitWorkflowService + .fetchRemote({ + cwd: input.cwd, + remoteName: "origin", + }) + .pipe( + Effect.catch((cause) => + Effect.logDebug("NTBS fetch of origin failed; resolving against local state.", { + cwd: input.cwd, + cause, + }), + ), + ); + + return yield* gitWorkflowService + .resolveRemoteTrackingCommit({ + cwd: input.cwd, + refName: input.baseRef, + fallbackRemoteName: "origin", + }) + .pipe( + Effect.map((resolved) => ({ + refName: resolved.commitSha, + baseRefName: input.baseRef, + })), + Effect.catch((cause) => + Effect.logDebug("NTBS base ref is not a remote branch; using it as-is", { + baseRef: input.baseRef, + cwd: input.cwd, + cause, + }).pipe( + Effect.as({ + refName: input.baseRef, + baseRefName: null, + }), + ), + ), + ); + }); + + const orchestrationEngineService = yield* OrchestrationEngineService; + + const projectScriptRunner = yield* ProjectSetupScriptRunner; + + /** + * Semaphore-like behavior to avoid triggering multiple threads + * and turns for the same requests. + */ + const inFlightRequests = new Set(); + + /** + * Creates an isolated worktree and a new T3 thread. + * + * Uses the supplied project and base ref. The thread starts with T3's default + * title, the project's default model or T3's fallback model, `full-access` + * runtime mode, and `default` interaction mode. + * + * Does not start a turn, read platform data or call the adapter. + * + * The final title of the thread is generated by T3 after the first turn starts. + */ + const createT3Thread = (t3Context: T3Context): Effect.Effect => + Effect.gen(function* () { + const maybeProject = yield* projectionSnapshotQuery + .getProjectShellById(t3Context.projectId) + .pipe(orFail("Could not load the T3 Project.")); + + const project = yield* Effect.fromOption(maybeProject).pipe( + orFail(`T3 project ${t3Context.projectId} does not exist.`), + ); + + const threadUUID = yield* randomUUID; + const threadId = ThreadId.make(threadUUID); + + const createdAt = yield* getNow; + // TODO: Resolve the title in a better way + const title = DEFAULT_THREAD_TITLE; + const modelSelection = + project.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); + + const commandId = CommandId.make(yield* randomUUID); + + // create the isolated branch and worktree + const branchName = buildTemporaryWorktreeBranchName(() => threadUUID); + + const base = yield* resolveWorktreeBase({ + cwd: project.workspaceRoot, + baseRef: t3Context.baseRef, + }); + + const gitWorktree = yield* gitWorkflowService + .createWorktree({ + cwd: project.workspaceRoot, + refName: base.refName, + ...(base.baseRefName !== null + ? { + baseRefName: base.baseRefName, + } + : {}), + newRefName: branchName, + path: null, + // we run setup scripts later + deferDependencyInstall: true, + }) + .pipe(orFail("Could not create the T3 worktree")); + + yield* orchestrationEngineService + .dispatch( + OrchestrationCommand.make({ + type: "thread.create", + branch: gitWorktree.worktree.refName, + worktreePath: gitWorktree.worktree.path, + threadId: threadId, + title: title, + modelSelection: modelSelection, + commandId: commandId, + createdAt: createdAt, + projectId: project.id, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + }), + ) + .pipe( + /* + Removes the worktree, but deliberately not its temporary + branch: GitWorkflowService has no branch-delete operation + (branch retention is an invariant of the thread worktree + lifecycle — see WorktreeLifecycle.cleanupThreadWorktree), so + the orphaned `t3/wt-…` ref is an accepted leak. It is a + dangling ref to an existing commit and costs nothing beyond + ref-listing noise. + */ + Effect.onError(() => + gitWorkflowService + .removeWorktree({ + path: gitWorktree.worktree.path, + cwd: project.workspaceRoot, + /* We also want garbage collection, we cannot rely + on the directory to be pristine. + */ + force: true, + }) + .pipe( + Effect.catch((cleanupErr) => + Effect.logWarning( + "Failed to remove worktree after thread.create did not complete", + { + threadId, + path: gitWorktree.worktree.path, + cause: cleanupErr, + }, + ), + ), + ), + ), + orFail("Failed to create a T3 thread"), + ); + + yield* projectScriptRunner + .runForThread({ + threadId, + projectId: project.id, + projectCwd: project.workspaceRoot, + worktreePath: gitWorktree.worktree.path, + }) + .pipe( + Effect.catch((err) => + Effect.logWarning("NTBS thread setup script failed.", { + threadId, + cause: err, + }), + ), + ); + + return threadId; + }); + + /** + * Resumes one stored NTBS thread after the processor starts. + * + * Starts the original turn when it is missing, leaves active turns to the + * live event listener, or posts the outcome when a turn already finished. + */ + const recoverThread = ( + threadCreated: NTBS.ThreadCreated, + ): Effect.Effect => + Effect.gen(function* () { + const { threadId, userMessageId } = threadCreated.t3; + + const turn = yield* getTurn(threadId, userMessageId); + + if (!turn) { + yield* startT3Turn( + threadId, + userMessageId, + threadCreated.snapshot, + threadCreated.attachments, + ); + return; + } + + if (turn.state === "pending" || turn.state === "running") { + return; + } + + yield* ensureUniqueOutcome( + userMessageId, + Effect.gen(function* () { + const currentRecord = yield* adapter + .findByThreadId(threadId) + .pipe(orFail("Failed reloading the NTBS lifecycle during recovery")); + + if (currentRecord.state === "thread.response.posted") { + markResponsePosted(userMessageId); + return; + } + + const response = yield* resolveT3Outcome(threadId, userMessageId); + if (response !== null) { + yield* postResponse(currentRecord, response); + } + }), + ); + }); + + /* + Handles an external request in this order: + + 1. Ask the adapter whether this platform request already has a recorded + `ThreadCreated` or `ResponsePosted`. + If yes - stop. . If no - continue + 2. Create the worktree and T3 thread. + 3. Generate the first user message ID and record it with ThreadCreated. + 4. Start the first T3 turn with that message ID, the snapshot, and attachments. + 5. Attempt to post the acknowledgement independently. + */ + const process = (request: NTBS.Request, t3Context: T3Context) => + Effect.gen(function* () { + /* + In-flight dedup first. We check if the processor is *currently* + working on this very request: it's being worked right now. + Later we check for the *durable* dedup: are we receiving a request + for work that has *already* completed. + */ + const key = request.sourceUri; + + const isBeingWorkedNow = inFlightRequests.has(key); + if (isBeingWorkedNow) { + yield* Effect.logDebug("NTBS request already being worked on; dropping duplicate", { + key, + }); + return; + } + inFlightRequests.add(key); + + yield* Effect.gen(function* () { + // durable dedup + const existingRequest = yield* adapter + .findByRequest(request) + .pipe(orFail("Error getting the existing request in process")); + + if (existingRequest) { + return; + } + // create the worktree and T3 thread + const threadId = yield* createT3Thread(t3Context); + + // generate the first user message ID and record it with ThreadCreated + const userMessageId = MessageId.make(yield* randomUUID); + + const threadCreated: NTBS.ThreadCreated = { + ...request, + state: "thread.created", + t3: { + threadId, + userMessageId, + }, + }; + + yield* adapter + .save(threadCreated) + .pipe(orFail("Failed to record the created NTBS thread")); + + // Start the first T3 turn with that message Id, the snapshot and attachments + yield* startT3Turn(threadId, userMessageId, request.snapshot, request.attachments); + + yield* adapter.acknowledge(threadCreated).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed posting the NTBS acknowledgement", { + userMessageId, + threadId, + cause, + }), + ), + ); + }).pipe(Effect.ensuring(Effect.sync(() => inFlightRequests.delete(key)))); + }); + + const consumeT3Events = Stream.runForEach( + orchestrationEngineService.streamDomainEvents, + (event) => + processT3Event(event).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed processing T3 event for NTBS", { + eventType: event.type, + cause, + }), + ), + ), + ); + + const recoverStoredThreads = adapter.loadThreadsAwaitingResponse.pipe( + orFail("Failed loading NTBS threads awaiting a response"), + Effect.flatMap((threads) => + Effect.forEach( + threads, + (threadCreated) => + recoverThread(threadCreated).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed recovering an NTBS thread", { + threadId: threadCreated.t3.threadId, + userMessageId: threadCreated.t3.userMessageId, + cause, + }), + ), + ), + { discard: true }, + ), + ), + Effect.catch((cause) => + Effect.logError("Failed starting NTBS thread recovery", { + cause, + }), + ), + ); + + const run = Effect.scoped( + Effect.gen(function* () { + yield* consumeT3Events.pipe(Effect.forkScoped({ startImmediately: true })); + yield* recoverStoredThreads; + return yield* Effect.never; + }), + ); + + return { + process, + run, + }; + }); diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 5eccf9d3d61f..6b67a07dec2e 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,26 +1,9 @@ -import { - type ChatAttachment, - CommandId, - DEFAULT_PROVIDER_INTERACTION_MODE, - MessageId, - OrchestrationCommand, - type OrchestrationEvent, - type ProjectId, - ThreadId, -} from "@t3tools/contracts"; +import { type ProjectId } from "@t3tools/contracts"; import type * as NTBS from "./exchange.ts"; -import { Context, Crypto, Data, DateTime, Effect, Stream, Semaphore } from "effect"; +import { Context, Data, Effect } from "effect"; import { NTBSAdapter } from "./adapter.ts"; -import type { T3Gateway } from "./t3gateway.ts"; +import { T3Gateway } from "./t3gateway.ts"; import type { ExchangeRepository } from "./ExchangeRepository.ts"; -import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; -import { GitWorkflowService } from "../git/GitWorkflowService.ts"; -import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; -import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; -import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; /* The processor is the executor and orchestrator of non-turn-based surfaces: it applies the business rules and connects T3 to the external platform. It does so through three services: @@ -112,528 +95,11 @@ type NTBSProcessorRequirements = export const makeNTBSProcessor: Effect.Effect = Effect.gen(function* () { const adapter = yield* NTBSAdapter; + const t3 = yield* T3Gateway; const orFail = (reason: string) => Effect.mapError((cause: unknown) => new NTBSProcessorError({ reason, cause })); - const crypto = yield* Crypto.Crypto; - const randomUUID = crypto.randomUUIDv4.pipe(orFail("Failed creating a UUID v4")); - - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const projectionTurnRepository = yield* ProjectionTurnRepository; - - const gitWorkflowService = yield* GitWorkflowService; - - const getNow = DateTime.now.pipe(Effect.map(DateTime.formatIso)); - - /** Keeps each user message's lock until its final response is recorded and no caller uses it. */ - const responseLocks = new Map< - MessageId, - { - readonly semaphore: Semaphore.Semaphore; - callers: number; - responsePosted: boolean; - } - >(); - - const getResponseLock = (userMessageId: MessageId) => { - let lock = responseLocks.get(userMessageId); - if (lock === undefined) { - lock = { - semaphore: Semaphore.makeUnsafe(1), - callers: 0, - responsePosted: false, - }; - responseLocks.set(userMessageId, lock); - } - return lock; - }; - - const markResponsePosted = (userMessageId: MessageId): void => { - const lock = responseLocks.get(userMessageId); - if (lock !== undefined) { - lock.responsePosted = true; - } - }; - - /** - * Prevents the turn started by one user message from producing competing - * final outcomes, such as both a normal response and a timeout. - */ - const ensureUniqueOutcome = ( - userMessageId: MessageId, - effect: Effect.Effect, - ): Effect.Effect => - Effect.suspend(() => { - const lock = getResponseLock(userMessageId); - lock.callers += 1; - - return lock.semaphore.withPermit(effect).pipe( - Effect.ensuring( - Effect.sync(() => { - lock.callers -= 1; - if ( - lock.callers === 0 && - lock.responsePosted && - responseLocks.get(userMessageId) === lock - ) { - responseLocks.delete(userMessageId); - } - }), - ), - ); - }); - - /** - * Starts the first turn in an existing T3 thread. - * - * Uses the user message ID recorded in `ThreadCreated` so the resulting turn - * and response can be matched to the external request. - */ - const startT3Turn = ( - threadId: ThreadId, - userMessageId: MessageId, - snapshot: string, - attachments: ReadonlyArray, - ): Effect.Effect => - Effect.gen(function* () { - const commandId = CommandId.make(yield* randomUUID); - const createdAt = yield* getNow; - - yield* orchestrationEngineService - .dispatch( - OrchestrationCommand.make({ - type: "thread.turn.start", - commandId, - threadId, - message: { - messageId: userMessageId, - role: "user", - text: snapshot, - attachments, - }, - runtimeMode: "full-access", - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - createdAt, - }), - ) - .pipe(orFail("Failed to start the first T3 turn")); - }); - - const getTurn = (threadId: ThreadId, userMessageId: MessageId) => - Effect.gen(function* () { - const turns = yield* projectionTurnRepository - .listByThreadId({ threadId }) - .pipe(orFail(`Failed loading turns for T3 thread ${threadId}`)); - /* - Using `.find` is safe: a userMessageId can never label more than one turn. - The UUID is minted once per request, and a turn start is only repeated - (by recovery) when no turn exists for it. - If the turn started, `.find` finds it. Finding none means the turn never - started (e.g. crash) or T3 discarded it before a provider picked it up. - */ - const turn = turns.find((turn) => turn.pendingMessageId === userMessageId); - return turn ?? null; - }); - - /** - * Reads the final outcome of the turn started by one NTBS user message. - * Returns `null` while that exact turn is still pending or running. - */ - const resolveT3Outcome = ( - threadId: ThreadId, - userMessageId: MessageId, - ): Effect.Effect => - Effect.gen(function* () { - const turn = yield* getTurn(threadId, userMessageId); - - if (!turn) { - return yield* new NTBSProcessorError({ - reason: `Turn for user message ${userMessageId} not found.`, - cause: { threadId, userMessageId }, - }); - } - - if (turn.state === "pending" || turn.state === "running") { - return null; - } - - const maybeThread = yield* projectionSnapshotQuery - .getThreadDetailById(threadId) - .pipe(orFail(`Failed loading T3 thread ${threadId}`)); - - const thread = yield* Effect.fromOption(maybeThread).pipe( - orFail(`Could not find T3 thread ${threadId}`), - ); - - if (turn.state === "completed") { - const assistantMessage = - turn.assistantMessageId === null - ? undefined - : thread.messages.find((message) => message.id === turn.assistantMessageId); - - const text = assistantMessage?.text.trim() ?? ""; - - return text.length > 0 - ? { type: "answer", text } - : { - type: "failure", - text: "T3 completed without producing a response.", - }; - } - - if (turn.state === "error") { - return { - type: "failure", - text: thread.session?.lastError ?? "T3 failed while processing this request.", - }; - } - - return { - type: "cancellation", - text: "T3 stopped processing this request.", - }; - }); - - /** - * Posts one final response and records it in the adapter lifecycle. - * - * The caller must have already confirmed that no response is recorded and - * must hold the outcome lock for this user message. If the platform already - * contains the response, it is recorded instead of reposted. - */ - const postResponse = ( - threadCreated: NTBS.ThreadCreated, - response: NTBSResponse, - ): Effect.Effect => - Effect.gen(function* () { - const existingResponseMessageId = yield* adapter - .findMatchingResponseMessage(threadCreated) - .pipe(orFail("Failed checking whether the NTBS response was already posted")); - - const responseMessageId = - existingResponseMessageId ?? - (yield* adapter - .postResponse(threadCreated, response) - .pipe(orFail("Failed posting the NTBS response"))); - - yield* adapter - .save({ - ...threadCreated, - state: "thread.response.posted", - responseMessageId, - }) - .pipe(orFail("Failed recording the posted NTBS response")); - - markResponsePosted(threadCreated.t3.userMessageId); - }); - - /** - * Posts the final response when a T3 session event ends an NTBS turn. - * Other T3 events and threads unknown to this adapter are ignored. - */ - const processT3Event = (event: OrchestrationEvent): Effect.Effect => - Effect.gen(function* () { - if (event.type !== "thread.session-set") { - return; - } - - const threadId = event.payload.threadId; - - /* - We may receive events for threads that are not related to the current - platform, and thus, adapter. - So we check if the thread in question exists in the adapter records. - */ - const recordedThread = yield* adapter.findByThreadId(threadId).pipe( - Effect.catchTag("ThreadNotFound", () => Effect.succeed(null)), - orFail("Failed loading the NTBS lifecycle for a T3 event"), - ); - - if (recordedThread === null) { - return; - } - - /* - At the same time a thread may have different messages. We're only interested - in the last user message that appears in the adapter records. - */ - const userMessageId = recordedThread.t3.userMessageId; - - yield* ensureUniqueOutcome( - userMessageId, - Effect.gen(function* () { - // Timeout handling may have posted a response while this event was - // waiting for the same user message's outcome lock. - const currentRecord = yield* adapter.findByThreadId(threadId).pipe( - Effect.catchTag("ThreadNotFound", () => Effect.succeed(null)), - orFail("Failed reloading the NTBS lifecycle before posting its outcome"), - ); - - if (currentRecord === null) { - return; - } - - if (currentRecord.state === "thread.response.posted") { - markResponsePosted(userMessageId); - return; - } - - const response = yield* resolveT3Outcome(threadId, userMessageId); - if (response === null) { - return; - } - - yield* postResponse(currentRecord, response); - }), - ); - }); - - /** - * Resolves where a new thread worktree starts from. - * - * Fetches `origin` and prefers the remote state of `baseRef`, so a branch name resolves to its latest remote commit even when the local copy is behind. - * - * When no remote branch with that name exists - * (a commit SHA, a tag, a local-only branch, or no reachable remote), - * the ref is returned as-is for git to resolve during worktree creation. - * - * Never fails: an unresolvable ref surfaces later as a worktree-creation error, - * which carries the real git cause. - * - */ - const resolveWorktreeBase = (input: { - readonly cwd: string; - readonly baseRef: string; - }): Effect.Effect<{ readonly refName: string; readonly baseRefName: string | null }> => - Effect.gen(function* () { - // A failed fetch only means we resolve against the last-known remote state - // The tracking ref may still exist locally - yield* gitWorkflowService - .fetchRemote({ - cwd: input.cwd, - remoteName: "origin", - }) - .pipe( - Effect.catch((cause) => - Effect.logDebug("NTBS fetch of origin failed; resolving against local state.", { - cwd: input.cwd, - cause, - }), - ), - ); - - return yield* gitWorkflowService - .resolveRemoteTrackingCommit({ - cwd: input.cwd, - refName: input.baseRef, - fallbackRemoteName: "origin", - }) - .pipe( - Effect.map((resolved) => ({ - refName: resolved.commitSha, - baseRefName: input.baseRef, - })), - Effect.catch((cause) => - Effect.logDebug("NTBS base ref is not a remote branch; using it as-is", { - baseRef: input.baseRef, - cwd: input.cwd, - cause, - }).pipe( - Effect.as({ - refName: input.baseRef, - baseRefName: null, - }), - ), - ), - ); - }); - - const orchestrationEngineService = yield* OrchestrationEngineService; - - const projectScriptRunner = yield* ProjectSetupScriptRunner; - - /** - * Semaphore-like behavior to avoid triggering multiple threads - * and turns for the same requests. - */ - const inFlightRequests = new Set(); - - /** - * Creates an isolated worktree and a new T3 thread. - * - * Uses the supplied project and base ref. The thread starts with T3's default - * title, the project's default model or T3's fallback model, `full-access` - * runtime mode, and `default` interaction mode. - * - * Does not start a turn, read platform data or call the adapter. - * - * The final title of the thread is generated by T3 after the first turn starts. - */ - const createT3Thread = (t3Context: T3Context): Effect.Effect => - Effect.gen(function* () { - const maybeProject = yield* projectionSnapshotQuery - .getProjectShellById(t3Context.projectId) - .pipe(orFail("Could not load the T3 Project.")); - - const project = yield* Effect.fromOption(maybeProject).pipe( - orFail(`T3 project ${t3Context.projectId} does not exist.`), - ); - - const threadUUID = yield* randomUUID; - const threadId = ThreadId.make(threadUUID); - - const createdAt = yield* getNow; - // TODO: Resolve the title in a better way - const title = DEFAULT_THREAD_TITLE; - const modelSelection = - project.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); - - const commandId = CommandId.make(yield* randomUUID); - - // create the isolated branch and worktree - const branchName = buildTemporaryWorktreeBranchName(() => threadUUID); - - const base = yield* resolveWorktreeBase({ - cwd: project.workspaceRoot, - baseRef: t3Context.baseRef, - }); - - const gitWorktree = yield* gitWorkflowService - .createWorktree({ - cwd: project.workspaceRoot, - refName: base.refName, - ...(base.baseRefName !== null - ? { - baseRefName: base.baseRefName, - } - : {}), - newRefName: branchName, - path: null, - // we run setup scripts later - deferDependencyInstall: true, - }) - .pipe(orFail("Could not create the T3 worktree")); - - yield* orchestrationEngineService - .dispatch( - OrchestrationCommand.make({ - type: "thread.create", - branch: gitWorktree.worktree.refName, - worktreePath: gitWorktree.worktree.path, - threadId: threadId, - title: title, - modelSelection: modelSelection, - commandId: commandId, - createdAt: createdAt, - projectId: project.id, - runtimeMode: "full-access", - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - }), - ) - .pipe( - /* - Removes the worktree, but deliberately not its temporary - branch: GitWorkflowService has no branch-delete operation - (branch retention is an invariant of the thread worktree - lifecycle — see WorktreeLifecycle.cleanupThreadWorktree), so - the orphaned `t3/wt-…` ref is an accepted leak. It is a - dangling ref to an existing commit and costs nothing beyond - ref-listing noise. - */ - Effect.onError(() => - gitWorkflowService - .removeWorktree({ - path: gitWorktree.worktree.path, - cwd: project.workspaceRoot, - /* We also want garbage collection, we cannot rely - on the directory to be pristine. - */ - force: true, - }) - .pipe( - Effect.catch((cleanupErr) => - Effect.logWarning( - "Failed to remove worktree after thread.create did not complete", - { - threadId, - path: gitWorktree.worktree.path, - cause: cleanupErr, - }, - ), - ), - ), - ), - orFail("Failed to create a T3 thread"), - ); - - yield* projectScriptRunner - .runForThread({ - threadId, - projectId: project.id, - projectCwd: project.workspaceRoot, - worktreePath: gitWorktree.worktree.path, - }) - .pipe( - Effect.catch((err) => - Effect.logWarning("NTBS thread setup script failed.", { - threadId, - cause: err, - }), - ), - ); - - return threadId; - }); - - /** - * Resumes one stored NTBS thread after the processor starts. - * - * Starts the original turn when it is missing, leaves active turns to the - * live event listener, or posts the outcome when a turn already finished. - */ - const recoverThread = ( - threadCreated: NTBS.ThreadCreated, - ): Effect.Effect => - Effect.gen(function* () { - const { threadId, userMessageId } = threadCreated.t3; - - const turn = yield* getTurn(threadId, userMessageId); - - if (!turn) { - yield* startT3Turn( - threadId, - userMessageId, - threadCreated.snapshot, - threadCreated.attachments, - ); - return; - } - - if (turn.state === "pending" || turn.state === "running") { - return; - } - - yield* ensureUniqueOutcome( - userMessageId, - Effect.gen(function* () { - const currentRecord = yield* adapter - .findByThreadId(threadId) - .pipe(orFail("Failed reloading the NTBS lifecycle during recovery")); - - if (currentRecord.state === "thread.response.posted") { - markResponsePosted(userMessageId); - return; - } - - const response = yield* resolveT3Outcome(threadId, userMessageId); - if (response !== null) { - yield* postResponse(currentRecord, response); - } - }), - ); - }); - /* Handles an external request in this order: @@ -645,113 +111,9 @@ export const makeNTBSProcessor: Effect.Effect - Effect.gen(function* () { - /* - In-flight dedup first. We check if the processor is *currently* - working on this very request: it's being worked right now. - Later we check for the *durable* dedup: are we receiving a request - for work that has *already* completed. - */ - const key = request.sourceUri; - - const isBeingWorkedNow = inFlightRequests.has(key); - if (isBeingWorkedNow) { - yield* Effect.logDebug("NTBS request already being worked on; dropping duplicate", { - key, - }); - return; - } - inFlightRequests.add(key); - - yield* Effect.gen(function* () { - // durable dedup - const existingRequest = yield* adapter - .findByRequest(request) - .pipe(orFail("Error getting the existing request in process")); - - if (existingRequest) { - return; - } - // create the worktree and T3 thread - const threadId = yield* createT3Thread(t3Context); - - // generate the first user message ID and record it with ThreadCreated - const userMessageId = MessageId.make(yield* randomUUID); - - const threadCreated: NTBS.ThreadCreated = { - ...request, - state: "thread.created", - t3: { - threadId, - userMessageId, - }, - }; - - yield* adapter - .save(threadCreated) - .pipe(orFail("Failed to record the created NTBS thread")); - - // Start the first T3 turn with that message Id, the snapshot and attachments - yield* startT3Turn(threadId, userMessageId, request.snapshot, request.attachments); - - yield* adapter.acknowledge(threadCreated).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed posting the NTBS acknowledgement", { - userMessageId, - threadId, - cause, - }), - ), - ); - }).pipe(Effect.ensuring(Effect.sync(() => inFlightRequests.delete(key)))); - }); - - const consumeT3Events = Stream.runForEach( - orchestrationEngineService.streamDomainEvents, - (event) => - processT3Event(event).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed processing T3 event for NTBS", { - eventType: event.type, - cause, - }), - ), - ), - ); - - const recoverStoredThreads = adapter.loadThreadsAwaitingResponse.pipe( - orFail("Failed loading NTBS threads awaiting a response"), - Effect.flatMap((threads) => - Effect.forEach( - threads, - (threadCreated) => - recoverThread(threadCreated).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed recovering an NTBS thread", { - threadId: threadCreated.t3.threadId, - userMessageId: threadCreated.t3.userMessageId, - cause, - }), - ), - ), - { discard: true }, - ), - ), - Effect.catch((cause) => - Effect.logError("Failed starting NTBS thread recovery", { - cause, - }), - ), - ); + const process = (request: NTBS.Request, t3Context: T3Target) => Effect.void; - const run = Effect.scoped( - Effect.gen(function* () { - yield* consumeT3Events.pipe(Effect.forkScoped({ startImmediately: true })); - yield* recoverStoredThreads; - return yield* Effect.never; - }), - ); + const run = Effect.never; return { process, From efd346852c2c36aec9ec9d8036589c3d984816ea Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 16:15:17 +0200 Subject: [PATCH 095/110] update --- apps/server/src/ntbs/processor.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 6b67a07dec2e..0ab4ed56298e 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -3,7 +3,7 @@ import type * as NTBS from "./exchange.ts"; import { Context, Data, Effect } from "effect"; import { NTBSAdapter } from "./adapter.ts"; import { T3Gateway } from "./t3gateway.ts"; -import type { ExchangeRepository } from "./ExchangeRepository.ts"; +import { ExchangeRepository } from "./ExchangeRepository.ts"; /* The processor is the executor and orchestrator of non-turn-based surfaces: it applies the business rules and connects T3 to the external platform. It does so through three services: @@ -96,6 +96,7 @@ export const makeNTBSProcessor: Effect.Effect Effect.mapError((cause: unknown) => new NTBSProcessorError({ reason, cause })); @@ -111,7 +112,15 @@ export const makeNTBSProcessor: Effect.Effect Effect.void; + const process = (request: NTBS.Request, t3Context: T3Target) => + Effect.gen(function* () { + const sourceId = request.sourceUri; + const maybeExchange = yield* repo.findBySourceUri(sourceId); + + if (!maybeExchange) { + // we kick off whatever we need to do + } + }); const run = Effect.never; From 08b6be4af55f5d38c43f45cad3738ea6de649071 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 16:53:05 +0200 Subject: [PATCH 096/110] feat: start work --- apps/server/src/ntbs/exchange.ts | 42 ++++++++++++++++++++----------- apps/server/src/ntbs/processor.ts | 5 +++- apps/server/src/ntbs/t3gateway.ts | 4 +-- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index e63ff5e57a40..e4fe48699c9b 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -65,23 +65,31 @@ export type UndeliverableCause = { readonly message: string; }; +/** Stable identifiers and locations for an exchange's T3 work. */ +export type T3WorkCoordinates = { + readonly projectId: ProjectId; + /** + * The commit used to create the T3 worktree. + * We keep the same SHA across retries so the request always runs against the + * code selected when it was claimed, even if the original branch moves later. + */ + readonly baseRefSha: string; + // Planned while RequestClaimed; confirmed by ThreadCreated. + readonly threadId: ThreadId; + /** + * The first T3 user message created for this external request. + * This identifies the correct turn and reply even if the thread later + * receives other messages. + */ + readonly userMessageId: MessageId; + readonly branchName: string; +}; + /** * The base data type common to all members of ExchangeState */ export type ExchangeStateBase = Request & { - readonly t3: { - readonly projectId: ProjectId; - readonly baseRef: string; - // Planned while RequestClaimed; confirmed by ThreadCreated. - readonly threadId: ThreadId; - /** - * The first T3 user message created for this external request. - * This identifies the correct turn and reply even if the thread later - * receives other messages. - */ - readonly userMessageId: MessageId; - readonly branchName: string; - }; + readonly t3: T3WorkCoordinates; }; /** @@ -169,8 +177,12 @@ export const isTerminalState = (state: ExchangeState): state is TerminalExchange export const isNonTerminalState = (state: ExchangeState): state is NonTerminalExchangeState => !isTerminalState(state); -export const makeRequestClaimed = (input: Omit): RequestClaimed => ({ - ...input, +export const makeRequestClaimed = ( + request: Request, + coordinates: T3WorkCoordinates, +): RequestClaimed => ({ + ...request, + t3: coordinates, tag: "request-claimed", }); diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 0ab4ed56298e..34f3cba0a679 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,5 +1,6 @@ import { type ProjectId } from "@t3tools/contracts"; import type * as NTBS from "./exchange.ts"; +import { makeRequestClaimed } from "./exchange.ts"; import { Context, Data, Effect } from "effect"; import { NTBSAdapter } from "./adapter.ts"; import { T3Gateway } from "./t3gateway.ts"; @@ -118,7 +119,9 @@ export const makeNTBSProcessor: Effect.Effect {} export interface T3Gateway { - /** Mints the planned thread, message and branch identifiers recorded at claim. */ + /** Resolves the requested base ref to a commit SHA and mints the thread, message, and branch IDs recorded at claim. */ readonly planT3Work: (input: { readonly projectId: ProjectId; readonly baseRef: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly getThreadStatus: ( state: NTBS.RequestClaimed, From 4ff48db98d66b1670179a37781a1d7c4b662b7dc Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 18:39:30 +0200 Subject: [PATCH 097/110] chore: bump processor with a lock --- apps/server/src/ntbs/ExchangeRepository.ts | 36 ++- apps/server/src/ntbs/exchange.test.ts | 4 +- apps/server/src/ntbs/exchange.ts | 35 ++- apps/server/src/ntbs/processor.test.ts | 47 ++++ apps/server/src/ntbs/processor.ts | 259 +++++++++++++++++++-- apps/server/src/ntbs/processor2.test.ts | 6 +- apps/server/src/ntbs/test-helpers.ts | 6 +- 7 files changed, 323 insertions(+), 70 deletions(-) diff --git a/apps/server/src/ntbs/ExchangeRepository.ts b/apps/server/src/ntbs/ExchangeRepository.ts index 1dc35f3778cc..ed0b6e43a241 100644 --- a/apps/server/src/ntbs/ExchangeRepository.ts +++ b/apps/server/src/ntbs/ExchangeRepository.ts @@ -1,5 +1,5 @@ /* - * Defines the repository for durable NTBS exchange state. + * Defines the repository for durable NTBS exchanges. * * An exchange links an admitted external-platform request to its planned T3 * work and tracks its progress through delivery of the eventual reply. @@ -10,11 +10,7 @@ * platform. */ import { Array, Effect, Context, Data, HashMap, Ref, Layer } from "effect"; -import { - isNonTerminalState, - type ExchangeState, - type NonTerminalExchangeState, -} from "./exchange.ts"; +import { isNonTerminal, type Exchange, type NonTerminalExchange } from "./exchange.ts"; import type { ThreadId } from "@t3tools/contracts"; import { isSome } from "effect/Option"; @@ -26,19 +22,19 @@ export class ExchangeRepositoryError extends Data.TaggedError("ExchangeRepositor export interface ExchangeRepository { readonly findBySourceUri: ( sourceUri: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly findByThreadId: ( threadId: ThreadId, - ) => Effect.Effect; + ) => Effect.Effect; readonly findNonTerminalExchanges: Effect.Effect< - ReadonlyArray, + ReadonlyArray, ExchangeRepositoryError >; /** Inserts or replaces the exchange identified by its `sourceUri`. */ - readonly upsert: (state: ExchangeState) => Effect.Effect; + readonly upsert: (exchange: Exchange) => Effect.Effect; } export const ExchangeRepository = Context.Service( @@ -46,32 +42,32 @@ export const ExchangeRepository = Context.Service( ); const inMemoryER: Effect.Effect = Effect.gen(function* () { - const exchanges: Ref.Ref> = yield* Ref.make( - HashMap.empty(), + const exchanges: Ref.Ref> = yield* Ref.make( + HashMap.empty(), ); - const upsert = Effect.fn("ExchangeRepository.upsert")(function* (state: ExchangeState) { + const upsert = Effect.fn("ExchangeRepository.upsert")(function* (exchange: Exchange) { // we return conflicting source Uri as the first argument // in case we find that the same threadId belongs already to a different sourceUri const conflictingSourceUri = yield* Ref.modify(exchanges, (map) => { const conflict = HashMap.findFirst( map, (existing, sourceUri) => - sourceUri !== state.sourceUri && existing.t3.threadId === state.t3.threadId, + sourceUri !== exchange.sourceUri && existing.t3.threadId === exchange.t3.threadId, ); return isSome(conflict) ? [conflict.value[0], map] - : [null, HashMap.set(map, state.sourceUri, state)]; + : [null, HashMap.set(map, exchange.sourceUri, exchange)]; }); if (conflictingSourceUri !== null) { return yield* new ExchangeRepositoryError({ - reason: `Thread ${state.t3.threadId} already belongs to exchange ${conflictingSourceUri}`, + reason: `Thread ${exchange.t3.threadId} already belongs to exchange ${conflictingSourceUri}`, cause: { - threadId: state.t3.threadId, + threadId: exchange.t3.threadId, existingSourceUri: conflictingSourceUri, - incomingSourceUri: state.sourceUri, + incomingSourceUri: exchange.sourceUri, }, }); } @@ -86,7 +82,7 @@ const inMemoryER: Effect.Effect = Effect.gen(function* () { const findByThreadId = (threadId: ThreadId) => Ref.get(exchanges).pipe( Effect.map((map) => HashMap.filter(map, (val) => val.t3.threadId === threadId)), - // if we get more than one ExchangeState in the HashMap, something's wrong + // if we get more than one Exchange in the HashMap, something's wrong Effect.andThen((map) => HashMap.size(map) > 1 ? new ExchangeRepositoryError({ @@ -104,7 +100,7 @@ const inMemoryER: Effect.Effect = Effect.gen(function* () { Effect.map((arr) => Array.filter( arr.map((el) => el[1]), - isNonTerminalState, + isNonTerminal, ), ), ); diff --git a/apps/server/src/ntbs/exchange.test.ts b/apps/server/src/ntbs/exchange.test.ts index 8525eb22474f..368408b72d4f 100644 --- a/apps/server/src/ntbs/exchange.test.ts +++ b/apps/server/src/ntbs/exchange.test.ts @@ -8,7 +8,7 @@ import { toReplyPosted, toThreadCreated, toUndeliverable, - type ExchangeStateBase, + type ExchangeBase, type ReplyPosted, type RequestClaimed, } from "./exchange.ts"; @@ -25,7 +25,7 @@ const exchangeStateBase = { userMessageId: MessageId.make("messageId"), branchName: "branchName", }, -} satisfies ExchangeStateBase; +} satisfies ExchangeBase; describe("RequestClaimed", () => { const claimed = makeRequestClaimed(exchangeStateBase); diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index e4fe48699c9b..6e3f1cf9afe1 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -86,9 +86,9 @@ export type T3WorkCoordinates = { }; /** - * The base data type common to all members of ExchangeState + * The data every exchange carries, whatever state it has reached. */ -export type ExchangeStateBase = Request & { +export type ExchangeBase = Request & { readonly t3: T3WorkCoordinates; }; @@ -99,14 +99,14 @@ export type ExchangeStateBase = Request & { * * From here, the processor alone drives the exchange to a terminal state. */ -export type RequestClaimed = ExchangeStateBase & { +export type RequestClaimed = ExchangeBase & { readonly tag: "request-claimed"; }; /** * The planned T3 thread exists. The first turn may not have started yet. Turn existence and progress are T3-owned. */ -export type ThreadCreated = ExchangeStateBase & { +export type ThreadCreated = ExchangeBase & { readonly tag: "thread-created"; }; @@ -116,7 +116,7 @@ export type ThreadCreated = ExchangeStateBase & { * This state may also follow `RequestClaimed` directly after a definitive provisioning failure, so it and later states do not imply the thread existed. * Reply delivery needs only `sourceUri`. */ -export type ReplyPending = ExchangeStateBase & { +export type ReplyPending = ExchangeBase & { readonly tag: "reply-pending"; readonly reply: Reply; }; @@ -125,7 +125,7 @@ export type ReplyPending = ExchangeStateBase & { * Terminal state. * The platform accepted the reply; its message ID is stored. */ -export type ReplyPosted = ExchangeStateBase & { +export type ReplyPosted = ExchangeBase & { readonly tag: "reply-posted"; readonly reply: Reply; readonly replySourceUri: string; @@ -139,26 +139,26 @@ export type ReplyPosted = ExchangeStateBase & { * or locked (Jira/Github issue, Discord thread), the bot has been kicked, etc. * The tombstone keeps dedup intact and stops the processor from retrying forever. */ -export type Undeliverable = ExchangeStateBase & { +export type Undeliverable = ExchangeBase & { readonly tag: "undeliverable"; readonly reply: Reply; readonly cause: UndeliverableCause; }; -/** States for exchanges that still have work left to do. */ -export type NonTerminalExchangeState = RequestClaimed | ThreadCreated | ReplyPending; +/** Exchanges that still have work left to do. */ +export type NonTerminalExchange = RequestClaimed | ThreadCreated | ReplyPending; -/** States for exchanges that have finished, with the reply either posted or undeliverable. */ -export type TerminalExchangeState = ReplyPosted | Undeliverable; +/** Exchanges that have finished, with the reply either posted or undeliverable. */ +export type TerminalExchange = ReplyPosted | Undeliverable; /** - * The state of an exchange between an external platform and T3, from request - * claim through final-reply delivery. The exchange repository stores the latest - * state to track progress and resume non-terminal exchanges after a restart. + * One exchange between an external platform and T3, from request claim through + * final-reply delivery. The tag says how far it got; the repository stores the + * latest value per `sourceUri` so non-terminal exchanges resume after a restart. */ -export type ExchangeState = NonTerminalExchangeState | TerminalExchangeState; +export type Exchange = NonTerminalExchange | TerminalExchange; -export const isTerminalState = (state: ExchangeState): state is TerminalExchangeState => { +export const isTerminal = (state: Exchange): state is TerminalExchange => { // An exhaustive switch makes new lifecycle states require an explicit classification. // This way it is impossible to break the program semantics by adding a new state // and forgetting to deal with it, because it would not typecheck. @@ -174,8 +174,7 @@ export const isTerminalState = (state: ExchangeState): state is TerminalExchange } }; -export const isNonTerminalState = (state: ExchangeState): state is NonTerminalExchangeState => - !isTerminalState(state); +export const isNonTerminal = (state: Exchange): state is NonTerminalExchange => !isTerminal(state); export const makeRequestClaimed = ( request: Request, diff --git a/apps/server/src/ntbs/processor.test.ts b/apps/server/src/ntbs/processor.test.ts index 3f7dcf5bfc7a..ff8cb263fd0f 100644 --- a/apps/server/src/ntbs/processor.test.ts +++ b/apps/server/src/ntbs/processor.test.ts @@ -85,6 +85,53 @@ processes a new request into a T3 thread, starts its first turn, persists lifecy turn; recovery should start the original turn and monitor it. */ +/* + TODO: Test processor-owned exchange serialization thoroughly. + + `process` first checks the repository and only then plans and persists a new + `RequestClaimed`. Without serialization, two concurrent deliveries carrying + the same `sourceUri` can both observe a missing exchange, mint different T3 + coordinates, and provision competing threads. Repository uniqueness alone + does not make that read-then-write sequence atomic. + + The processor should therefore serialize all work for one `sourceUri` while + allowing unrelated exchanges to run concurrently. A duplicate must wait for + the current caller and then perform the repository lookup again. It must not + merely be dropped: if the first caller fails before persisting its claim, the + waiting caller must get an opportunity to claim the request. + + Cover at least these cases using `Deferred` gates rather than sleeps: + + - Two simultaneous successful deliveries with the same `sourceUri`: block + the first during planning or persistence, start the second, and prove that + only one plan, claim, thread, turn, and acknowledgement are produced. Once + the first finishes, the second must re-read the repository and return as a + no-op. + - The first same-source caller fails before `RequestClaimed` is persisted: + the queued caller must acquire the lock afterward, observe no exchange, + and successfully claim and advance it. + - The first caller fails after persisting `RequestClaimed`: the queued caller + must observe the durable claim and return without planning new coordinates + or trying to repair the exchange. + - Different `sourceUri`s: block one request and prove that another request can + still plan and advance. The lock must be keyed, not global. + - Cancellation or interruption while holding the lock: the permit must be + released and the next caller must proceed. + - Cancellation or interruption while waiting for the lock: caller tracking + must be cleaned up without deleting a lock still used by another caller. + - Lock cleanup after the last caller exits, on both success and failure. The + lock map must not retain every `sourceUri` seen during the server lifetime. + - A platform redelivery racing with startup recovery or T3 thread activity: + every entry point must use the same source-keyed lock so stale state cannot + overwrite a newer transition and provisioning, turn start, or reply posting + cannot happen twice. + + Assert observable behavior rather than internal lock implementation wherever + possible. Count gateway, adapter, and repository calls; capture the exact T3 + coordinates and persisted states; and prove queued fibers are still blocked + by polling their completion before releasing each `Deferred` gate. +*/ + /* The processor's direct requirements are mocked one by one with `Layer.mock`: a method left out simply dies if the test path reaches it, so each test only diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 34f3cba0a679..2722a59cee3d 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,7 +1,6 @@ import { type ProjectId } from "@t3tools/contracts"; -import type * as NTBS from "./exchange.ts"; -import { makeRequestClaimed } from "./exchange.ts"; -import { Context, Data, Effect } from "effect"; +import * as NTBS from "./exchange.ts"; +import { Context, Data, Effect, Semaphore } from "effect"; import { NTBSAdapter } from "./adapter.ts"; import { T3Gateway } from "./t3gateway.ts"; import { ExchangeRepository } from "./ExchangeRepository.ts"; @@ -50,7 +49,7 @@ export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ export interface NTBSProcessor { /** - * Claims one external request and drives its exchange. + * Handles a request coming from an external platform. * * Does no filtering: the caller decides whether a request deserves T3 work, and everything passed here starts it. * @@ -74,6 +73,15 @@ export interface NTBSProcessor { export const makeNTBSProcessorTag = (key: string) => Context.Service(key); +type TransitionResult = + | { + readonly type: "transitioned"; + readonly state: NTBS.Exchange; + } + | { + readonly type: "unchanged"; + }; + type NTBSProcessorRequirements = /* Communicates with the external platform. Which platform is decided by the context the processor is built in. @@ -88,6 +96,11 @@ type NTBSProcessorRequirements = */ | ExchangeRepository; +type ExchangeLock = { + readonly semaphore: Semaphore.Semaphore; + callers: number; +}; + /** * Builds a processor for the adapter found in the context. * @@ -102,29 +115,227 @@ export const makeNTBSProcessor: Effect.Effect Effect.mapError((cause: unknown) => new NTBSProcessorError({ reason, cause })); - /* - Handles an external request in this order: - - 1. Ask the adapter whether this platform request already has a recorded - `ThreadCreated` or `ResponsePosted`. - If yes - stop. . If no - continue - 2. Create the worktree and T3 thread. - 3. Generate the first user message ID and record it with ThreadCreated. - 4. Start the first T3 turn with that message ID, the snapshot, and attachments. - 5. Attempt to post the acknowledgement independently. - */ - const process = (request: NTBS.Request, t3Context: T3Target) => - Effect.gen(function* () { - const sourceId = request.sourceUri; - const maybeExchange = yield* repo.findBySourceUri(sourceId); - - if (!maybeExchange) { - const coordinates = yield* t3.planT3Work(t3Context); - const claimed = makeRequestClaimed(request, coordinates); - yield* repo.upsert(claimed); + const transitionedTo = (state: NTBS.Exchange): TransitionResult => ({ + type: "transitioned", + state, + }); + + const unchanged: TransitionResult = { type: "unchanged" }; + + const failureReply = (failure: { + readonly reason: string; + readonly cause: unknown; + }): NTBS.ReplyFailure => ({ + type: "failure", + text: failure.reason, + cause: failure.cause, + }); + + const persist = (state: State) => + Effect.succeed(state).pipe( + Effect.tap(repo.upsert(state).pipe(orFail("Failed to persist the exchange state"))), + ); + + const exchangeLocks = new Map(); + + const withExchangeLock = (sourceUri: string, effect: Effect.Effect) => + Effect.suspend(() => { + let lock = exchangeLocks.get(sourceUri); + + if (lock === undefined) { + lock = { + semaphore: Semaphore.makeUnsafe(1), + callers: 0, + }; + exchangeLocks.set(sourceUri, lock); } + + lock.callers += 1; + + return lock.semaphore.withPermit(effect).pipe( + Effect.ensuring( + Effect.sync(() => { + lock.callers -= 1; + if (lock.callers === 0 && exchangeLocks.get(sourceUri) === lock) { + exchangeLocks.delete(sourceUri); + } + }), + ), + ); }); + const processRequestClaimed = Effect.fn("NTBSProcessor.processRequestClaimed")(function* ( + state: NTBS.RequestClaimed, + ) { + const context = yield* t3 + .getThreadStatus(state) + .pipe(orFail("Failed to get the T3 thread status")); + const decision = NTBS.fromRequestClaimed(state, context); + + switch (decision.type) { + case "provision-thread": { + const rejection = yield* t3.provisionThread(state).pipe( + Effect.as(null), + Effect.catchTag("T3Rejected", (error) => Effect.succeed(error)), + orFail("Failed to provision the T3 thread"), + ); + + if (rejection !== null) { + const next = yield* persist(NTBS.toReplyPending(state, failureReply(rejection))); + return transitionedTo(next); + } + + break; + } + + case "record-thread-created": + break; + } + + const next = yield* persist(NTBS.toThreadCreated(state)); + yield* adapter.acknowledge(next).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to post the NTBS acknowledgement", { + sourceUri: next.sourceUri, + threadId: next.t3.threadId, + cause, + }), + ), + ); + return transitionedTo(next); + }); + + const processThreadCreated = Effect.fn("NTBSProcessor.processThreadCreated")(function* ( + state: NTBS.ThreadCreated, + ) { + const context = yield* t3 + .getTurnStatus(state) + .pipe(orFail("Failed to get the T3 turn status")); + const decision = NTBS.fromThreadCreated(state, context); + + switch (decision.type) { + case "start-turn": { + const rejection = yield* t3.startTurn(state).pipe( + Effect.as(null), + Effect.catchTag("T3Rejected", (error) => Effect.succeed(error)), + orFail("Failed to start the T3 turn"), + ); + + if (rejection !== null) { + const next = yield* persist(NTBS.toReplyPending(state, failureReply(rejection))); + return transitionedTo(next); + } + + return unchanged; + } + + case "wait": + return unchanged; + + case "record-reply-pending": { + const next = yield* persist(NTBS.toReplyPending(state, decision.reply)); + return transitionedTo(next); + } + } + }); + + const processReplyPending = Effect.fn("NTBSProcessor.processReplyPending")(function* ( + state: NTBS.ReplyPending, + ) { + const replySourceUri = yield* adapter + .findPostedReply(state) + .pipe(orFail("Failed to find the posted platform reply")); + const context: NTBS.ReplyPendingContext = + replySourceUri === null + ? { platformReply: "missing" } + : { platformReply: "posted", replySourceUri }; + const decision = NTBS.fromReplyPending(state, context); + + switch (decision.type) { + case "post-reply": { + const delivery = yield* adapter.postReply(state).pipe( + Effect.map((postedReplySourceUri) => ({ + type: "posted" as const, + replySourceUri: postedReplySourceUri, + })), + Effect.catchTag("ReplyRejected", (error) => + Effect.succeed({ type: "rejected" as const, cause: error.cause }), + ), + orFail("Failed to post the platform reply"), + ); + + const next = yield* persist( + delivery.type === "posted" + ? NTBS.toReplyPosted(state, delivery.replySourceUri) + : NTBS.toUndeliverable(state, delivery.cause), + ); + return transitionedTo(next); + } + + case "record-reply-posted": { + const next = yield* persist(NTBS.toReplyPosted(state, decision.replySourceUri)); + return transitionedTo(next); + } + } + }); + + const advanceExchange = Effect.fn("NTBSProcessor.advanceExchange")(function* ( + initial: NTBS.Exchange, + ) { + let state = initial; + + while (NTBS.isNonTerminal(state)) { + let result: TransitionResult; + + switch (state.tag) { + case "request-claimed": + result = yield* processRequestClaimed(state); + break; + + case "thread-created": + result = yield* processThreadCreated(state); + break; + + case "reply-pending": + result = yield* processReplyPending(state); + break; + } + + if (result.type === "unchanged") { + return; + } + + state = result.state; + } + }); + + const process = (request: NTBS.Request, t3Target: T3Target) => + withExchangeLock( + request.sourceUri, + Effect.gen(function* () { + /* + 1. Check whether an Exchange exists for this source URI. + 2. If there is already - we can return. We treat duplicate deliveries of requests with the same sourceUri as duplicates. No ops. + 3. If there isn't we get the t3 coordinates, save them and advance the exchange. + */ + + const existing = yield* repo + .findBySourceUri(request.sourceUri) + .pipe(orFail("Failed to find the exchange for the platform request")); + + if (existing !== null) { + return; + } + + const coordinates = yield* t3 + .planT3Work(t3Target) + .pipe(orFail("Failed to plan the T3 work")); + const claimed = NTBS.makeRequestClaimed(request, coordinates); + yield* persist(claimed); + yield* advanceExchange(claimed); + }), + ); + const run = Effect.never; return { diff --git a/apps/server/src/ntbs/processor2.test.ts b/apps/server/src/ntbs/processor2.test.ts index 41fa1806c5cd..179044a64879 100644 --- a/apps/server/src/ntbs/processor2.test.ts +++ b/apps/server/src/ntbs/processor2.test.ts @@ -16,7 +16,7 @@ import { type NTBSAdapter, type NTBSResponse, } from "./adapter.ts"; -import type { Request, ExchangeState, ThreadCreated } from "./exchange.ts"; +import type { Request, Exchange, ThreadCreated } from "./exchange.ts"; import { makeNTBSProcessor, makeNTBSProcessorTag } from "./t3gateway.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -85,7 +85,7 @@ class TestAdapterState extends Context.Service< TestAdapterState, { /** Lifecycle records keyed by T3 thread — seed before acting, inspect after. */ - readonly records: Map; + readonly records: Map; readonly postedAcks: Array; readonly postedResponses: Array<{ readonly record: ThreadCreated; @@ -99,7 +99,7 @@ class TestAdapterState extends Context.Service< TestAdapterState, Effect.gen(function* () { return { - records: new Map(), + records: new Map(), postedAcks: [], postedResponses: [], threadLookups: yield* Queue.unbounded(), diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts index 9567ec092f45..5f546419bb52 100644 --- a/apps/server/src/ntbs/test-helpers.ts +++ b/apps/server/src/ntbs/test-helpers.ts @@ -7,7 +7,7 @@ import { ThreadId, VcsCreateWorktreeResult, } from "@t3tools/contracts"; -import type { Request, ExchangeState, ThreadCreated } from "./exchange.ts"; +import type { Request, Exchange, ThreadCreated } from "./exchange.ts"; import type { T3Context } from "./t3gateway.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { makeNTBSAdapterTag, ThreadNotFound, type NTBSResponse } from "./adapter.ts"; @@ -168,7 +168,7 @@ class TestAdapterState extends Context.Service< /** * Lifecycle records keyed by T3 thread. */ - readonly records: Map; + readonly records: Map; readonly postedAcks: Map; readonly postedResponses: Map< string, @@ -189,7 +189,7 @@ class TestAdapterState extends Context.Service< Effect.gen(function* () { return { // lifecycleEvents: [], - records: new Map(), + records: new Map(), postedAcks: new Map(), postedResponses: new Map< string, From 26bb942bbea88540cb4d353464f27c4836ee7b28 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 20:18:12 +0200 Subject: [PATCH 098/110] process refactor --- apps/server/src/ntbs/processor.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 2722a59cee3d..cf271b51433d 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -132,9 +132,7 @@ export const makeNTBSProcessor: Effect.Effect(state: State) => - Effect.succeed(state).pipe( - Effect.tap(repo.upsert(state).pipe(orFail("Failed to persist the exchange state"))), - ); + repo.upsert(state).pipe(orFail("Failed to persist the exchange state"), Effect.as(state)); const exchangeLocks = new Map(); @@ -309,8 +307,11 @@ export const makeNTBSProcessor: Effect.Effect - withExchangeLock( + const process = Effect.fn("NTBSProcessor.process")(function* ( + request: NTBS.Request, + t3Target: T3Target, + ) { + return yield* withExchangeLock( request.sourceUri, Effect.gen(function* () { /* @@ -335,6 +336,7 @@ export const makeNTBSProcessor: Effect.Effect Date: Mon, 24 Aug 2026 20:20:59 +0200 Subject: [PATCH 099/110] chore: update tests to use baseRefSha --- .../src/ntbs/ExchangeRepository.test.ts | 16 ++++--- apps/server/src/ntbs/exchange.test.ts | 48 ++++++++++++------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/server/src/ntbs/ExchangeRepository.test.ts b/apps/server/src/ntbs/ExchangeRepository.test.ts index 0b14b4ac263e..203926630e98 100644 --- a/apps/server/src/ntbs/ExchangeRepository.test.ts +++ b/apps/server/src/ntbs/ExchangeRepository.test.ts @@ -15,18 +15,20 @@ import { } from "./exchange.ts"; const makeExchange = (sourceUri: string, threadId: string) => - makeRequestClaimed({ - sourceUri, - snapshot: "request", - attachments: [], - t3: { + makeRequestClaimed( + { + sourceUri, + snapshot: "request", + attachments: [], + }, + { projectId: ProjectId.make("project"), - baseRef: "main", + baseRefSha: "base-ref-sha", threadId: ThreadId.make(threadId), userMessageId: MessageId.make(`message-${threadId}`), branchName: `branch-${threadId}`, }, - }); + ); describe("inMemoryExchangeRepository", () => { it.layer(inMemoryExchangeRepository)((it) => { diff --git a/apps/server/src/ntbs/exchange.test.ts b/apps/server/src/ntbs/exchange.test.ts index 368408b72d4f..51b25baa70f8 100644 --- a/apps/server/src/ntbs/exchange.test.ts +++ b/apps/server/src/ntbs/exchange.test.ts @@ -10,28 +10,36 @@ import { toUndeliverable, type ExchangeBase, type ReplyPosted, + type Request, type RequestClaimed, + type T3WorkCoordinates, } from "./exchange.ts"; import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; -const exchangeStateBase = { +const request = { sourceUri: "test://exchange/test", snapshot: "You need to imagine some text here", attachments: [], - t3: { - projectId: ProjectId.make("projectId"), - baseRef: "baseRef", - threadId: ThreadId.make("threadId"), - userMessageId: MessageId.make("messageId"), - branchName: "branchName", - }, +} satisfies Request; + +const coordinates = { + projectId: ProjectId.make("projectId"), + baseRefSha: "baseRefSha", + threadId: ThreadId.make("threadId"), + userMessageId: MessageId.make("messageId"), + branchName: "branchName", +} satisfies T3WorkCoordinates; + +const exchangeBase = { + ...request, + t3: coordinates, } satisfies ExchangeBase; describe("RequestClaimed", () => { - const claimed = makeRequestClaimed(exchangeStateBase); + const claimed = makeRequestClaimed(request, coordinates); it("makeRequestClaimed tags the base unchanged", () => { - expect(claimed).toEqual({ ...exchangeStateBase, tag: "request-claimed" }); + expect(claimed).toEqual({ ...exchangeBase, tag: "request-claimed" }); }); // if thread is missing we provision the thread @@ -44,13 +52,17 @@ describe("RequestClaimed", () => { }); it("toThreadCreated retags and carries every claim field forward", () => { - expect(toThreadCreated(claimed)).toEqual({ ...exchangeStateBase, tag: "thread-created" }); + expect(toThreadCreated(claimed)).toEqual({ ...exchangeBase, tag: "thread-created" }); }); it("provisioning failure jumps ahead with the reply stored verbatim", () => { - const reply = { type: "failure", text: "provisioning rejected" } as const; + const reply = { + type: "failure", + text: "provisioning rejected", + cause: "project not found", + } as const; expect(toReplyPending(claimed, reply)).toEqual({ - ...exchangeStateBase, + ...exchangeBase, tag: "reply-pending", reply, }); @@ -58,7 +70,7 @@ describe("RequestClaimed", () => { }); describe("ThreadCreated", () => { - const threadCreated = toThreadCreated(makeRequestClaimed(exchangeStateBase)); + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); const answer = { type: "answer", text: "The turn's final answer" } as const; // missing turn -> start it; active turn -> wait; completed turn -> record its reply @@ -75,7 +87,7 @@ describe("ThreadCreated", () => { it("completed turn's reply lands in ReplyPending verbatim", () => { expect(toReplyPending(threadCreated, answer)).toEqual({ - ...exchangeStateBase, + ...exchangeBase, tag: "reply-pending", reply: answer, }); @@ -85,7 +97,7 @@ describe("ThreadCreated", () => { describe("ReplyPending", () => { const reply = { type: "answer", text: "The turn's final answer" } as const; const replyPending = toReplyPending( - toThreadCreated(makeRequestClaimed(exchangeStateBase)), + toThreadCreated(makeRequestClaimed(request, coordinates)), reply, ); @@ -102,7 +114,7 @@ describe("ReplyPending", () => { it("accepted delivery lands in ReplyPosted with the platform message id", () => { expect(toReplyPosted(replyPending, "test://exchange/reply")).toEqual({ - ...exchangeStateBase, + ...exchangeBase, tag: "reply-posted", reply, replySourceUri: "test://exchange/reply", @@ -112,7 +124,7 @@ describe("ReplyPending", () => { it("definitive rejection lands in Undeliverable with the reply and cause", () => { const cause = { message: "original message was deleted" } as const; expect(toUndeliverable(replyPending, cause)).toEqual({ - ...exchangeStateBase, + ...exchangeBase, tag: "undeliverable", reply, cause, From 72c58d687d0ba433c28cb78cb3131f62365f20f9 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 20:25:21 +0200 Subject: [PATCH 100/110] chore: naming clean up --- apps/server/src/ntbs/processor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index cf271b51433d..a876c2ead213 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -122,7 +122,7 @@ export const makeNTBSProcessor: Effect.Effect ({ @@ -179,7 +179,7 @@ export const makeNTBSProcessor: Effect.Effect Date: Mon, 24 Aug 2026 21:02:10 +0200 Subject: [PATCH 101/110] chore: update processor --- apps/server/src/ntbs/processor.ts | 81 +++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index a876c2ead213..da479cf7a12d 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -1,6 +1,6 @@ -import { type ProjectId } from "@t3tools/contracts"; +import { type ProjectId, type ThreadId } from "@t3tools/contracts"; import * as NTBS from "./exchange.ts"; -import { Context, Data, Effect, Semaphore } from "effect"; +import { Context, Data, Effect, Semaphore, Stream } from "effect"; import { NTBSAdapter } from "./adapter.ts"; import { T3Gateway } from "./t3gateway.ts"; import { ExchangeRepository } from "./ExchangeRepository.ts"; @@ -278,7 +278,7 @@ export const makeNTBSProcessor: Effect.Effect + processThreadActivity(threadId).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to advance an exchange after T3 thread activity", { + threadId, + cause, + }), + ), + ), + ); + + const resumeNonTerminalExchanges = repo.findNonTerminalExchanges.pipe( + orFail("Failed to load non-terminal exchanges"), + Effect.flatMap((exchanges) => + Effect.forEach( + exchanges, + (exchange) => + advanceSavedExchange(exchange.sourceUri).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to recover an exchange", { + sourceUri: exchange.sourceUri, + threadId: exchange.t3.threadId, + cause, + }), + ), + ), + { discard: true }, + ), + ), + Effect.catch((cause) => + Effect.logError("Failed to start exchange recovery", { + cause, + }), + ), + ); + + const run = Effect.scoped( + Effect.gen(function* () { + yield* subscribeToThreadActivity.pipe(Effect.forkScoped({ startImmediately: true })); + yield* resumeNonTerminalExchanges; + return yield* Effect.never; + }), + ); return { process, From 47802fd0da80704bf07ef3c997827f99c614f5d4 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Mon, 24 Aug 2026 21:13:42 +0200 Subject: [PATCH 102/110] fix: type issue --- apps/server/src/ntbs/processor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index da479cf7a12d..d0393cce8128 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -278,7 +278,7 @@ export const makeNTBSProcessor: Effect.Effect Date: Mon, 24 Aug 2026 22:41:19 +0200 Subject: [PATCH 103/110] feat: write processor new tests --- apps/server/src/ntbs/processor-new.test.ts | 1814 ++++++++++++++++++++ apps/server/src/ntbs/processor.ts | 2 +- apps/server/src/ntbs/t3gateway.ts | 8 +- docs/planning/ntbs-todos.md | 24 + 4 files changed, 1843 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/ntbs/processor-new.test.ts diff --git a/apps/server/src/ntbs/processor-new.test.ts b/apps/server/src/ntbs/processor-new.test.ts new file mode 100644 index 000000000000..8b351c207946 --- /dev/null +++ b/apps/server/src/ntbs/processor-new.test.ts @@ -0,0 +1,1814 @@ +import { describe, expect, it } from "@effect/vitest"; +import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; +import { AdapterError, NTBSAdapter, ReplyRejected } from "./adapter.ts"; +import { ExchangeRepository, inMemoryExchangeRepository } from "./ExchangeRepository.ts"; +import { + makeRequestClaimed, + toReplyPending, + toReplyPosted, + toThreadCreated, + toUndeliverable, + type Exchange, + type ReplyPending, + type ReplyPosted, + type Request, + type T3WorkCoordinates, + type ThreadCreated, +} from "./exchange.ts"; +import { makeNTBSProcessor, type NTBSProcessor, type T3Target } from "./processor.ts"; +import { T3Gateway, T3GatewayError, T3Rejected } from "./t3gateway.ts"; + +/* +Every test in this module is about setting up the dependencies, and seeing what happens as we call `run` and `process` on the processor. + */ + +const withTestProcessor = ( + services: { + readonly t3: Partial; + readonly adapter: Partial; + }, + test: (context: { + readonly processor: NTBSProcessor; + readonly repository: ExchangeRepository; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const processor = yield* makeNTBSProcessor; + const repository = yield* ExchangeRepository; + + return yield* test({ processor, repository }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(T3Gateway)({ + threadActivity: Stream.never, + ...services.t3, + }), + Layer.mock(NTBSAdapter)(services.adapter), + inMemoryExchangeRepository, + ), + ), + ); + +const projectId = ProjectId.make("project-1"); + +const request: Request = { + sourceUri: "test://request/1", + snapshot: "Please fix the bug", + attachments: [], +}; + +const target: T3Target = { + projectId, + baseRef: "fork/dev", +}; + +const coordinates: T3WorkCoordinates = { + projectId, + baseRefSha: "base-ref-sha", + threadId: ThreadId.make("thread-1"), + userMessageId: MessageId.make("message-1"), + branchName: "ntbs/thread-1", +}; + +const secondRequest: Request = { + ...request, + sourceUri: "test://request/2", +}; + +const secondCoordinates: T3WorkCoordinates = { + ...coordinates, + baseRefSha: "second-base-ref-sha", + threadId: ThreadId.make("thread-2"), + userMessageId: MessageId.make("message-2"), + branchName: "ntbs/thread-2", +}; + +const waitForStoredState = ( + repository: ExchangeRepository, + sourceUri: string, + isExpected: (state: Exchange) => state is State, +) => + Effect.gen(function* () { + while (true) { + const state = yield* repository.findBySourceUri(sourceUri); + + if (state !== null && isExpected(state)) { + return state; + } + + yield* Effect.yieldNow; + } + }); + +describe("NTBSProcessor", () => { + describe("process", () => { + it.effect("starts a new request and ignores its sequential redelivery", () => { + const t3Calls: Array = []; + const acknowledgements: Array = []; + + return withTestProcessor( + { + t3: { + planT3Work: () => + Effect.sync(() => { + t3Calls.push("planT3Work"); + return coordinates; + }), + getThreadStatus: () => + Effect.sync(() => { + t3Calls.push("getThreadStatus"); + return { thread: "missing" as const }; + }), + provisionThread: () => + Effect.sync(() => { + t3Calls.push("provisionThread"); + }), + getTurnStatus: () => + Effect.sync(() => { + t3Calls.push("getTurnStatus"); + return { turn: "missing" as const }; + }), + startTurn: () => + Effect.sync(() => { + t3Calls.push("startTurn"); + }), + }, + adapter: { + acknowledge: (state) => + Effect.sync(() => { + acknowledgements.push(state); + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + yield* processor.process(request, target); + yield* processor.process(request, target); + + const expected = toThreadCreated(makeRequestClaimed(request, coordinates)); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + expect(acknowledgements).toEqual([expected]); + expect(t3Calls).toEqual([ + "planT3Work", + "getThreadStatus", + "provisionThread", + "getTurnStatus", + "startTurn", + ]); + }), + ); + }); + + it.effect("continues after a best-effort acknowledgement fails", () => { + let acknowledgementCalls = 0; + let startTurnCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => Effect.succeed(coordinates), + getThreadStatus: () => Effect.succeed({ thread: "present" }), + getTurnStatus: () => Effect.succeed({ turn: "missing" }), + startTurn: () => + Effect.sync(() => { + startTurnCalls += 1; + }), + }, + adapter: { + acknowledge: () => + Effect.gen(function* () { + acknowledgementCalls += 1; + return yield* new AdapterError({ + reason: "The acknowledgement could not be posted", + cause: "test failure", + }); + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + yield* processor.process(request, target); + + expect(acknowledgementCalls).toBe(1); + expect(startTurnCalls).toBe(1); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual( + toThreadCreated(makeRequestClaimed(request, coordinates)), + ); + }), + ); + }); + + it.effect("leaves an exchange unchanged while its turn is active", () => { + let startTurnCalls = 0; + let findReplyCalls = 0; + let postReplyCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => Effect.succeed(coordinates), + getThreadStatus: () => Effect.succeed({ thread: "present" }), + getTurnStatus: () => Effect.succeed({ turn: "active" }), + startTurn: () => + Effect.sync(() => { + startTurnCalls += 1; + }), + }, + adapter: { + acknowledge: () => Effect.void, + findPostedReply: () => + Effect.sync(() => { + findReplyCalls += 1; + return null; + }), + postReply: () => + Effect.sync(() => { + postReplyCalls += 1; + return "test://reply/unexpected"; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + yield* processor.process(request, target); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual( + toThreadCreated(makeRequestClaimed(request, coordinates)), + ); + expect(startTurnCalls).toBe(0); + expect(findReplyCalls).toBe(0); + expect(postReplyCalls).toBe(0); + }), + ); + }); + + it.effect("retries a transient provisioning failure during later recovery", () => { + const recoveredTurnStarted = Deferred.makeUnsafe(); + let provisionCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => Effect.succeed(coordinates), + getThreadStatus: () => Effect.succeed({ thread: "missing" }), + provisionThread: () => + Effect.gen(function* () { + provisionCalls += 1; + + if (provisionCalls === 1) { + return yield* new T3GatewayError({ + reason: "Thread provisioning temporarily failed", + cause: "test failure", + }); + } + }), + getTurnStatus: () => Effect.succeed({ turn: "missing" }), + startTurn: () => Deferred.succeed(recoveredTurnStarted, undefined), + }, + adapter: { + acknowledge: () => Effect.void, + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + expect((yield* Effect.exit(processor.process(request, target)))._tag).toBe("Failure"); + + const claimed = makeRequestClaimed(request, coordinates); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(claimed); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(recoveredTurnStarted); + + expect(provisionCalls).toBe(2); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual( + toThreadCreated(claimed), + ); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("retries a transient turn-start failure during later recovery", () => { + const recoveredTurnStarted = Deferred.makeUnsafe(); + let startTurnCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => Effect.succeed(coordinates), + getThreadStatus: () => Effect.succeed({ thread: "present" }), + getTurnStatus: () => Effect.succeed({ turn: "missing" }), + startTurn: () => + Effect.gen(function* () { + startTurnCalls += 1; + + if (startTurnCalls === 1) { + return yield* new T3GatewayError({ + reason: "Turn start temporarily failed", + cause: "test failure", + }); + } + + yield* Deferred.succeed(recoveredTurnStarted, undefined); + }), + }, + adapter: { + acknowledge: () => Effect.void, + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + expect((yield* Effect.exit(processor.process(request, target)))._tag).toBe("Failure"); + + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(threadCreated); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(recoveredTurnStarted); + + expect(startTurnCalls).toBe(2); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(threadCreated); + + yield* Fiber.interrupt(run); + }), + ); + }); + }); + + describe("source serialization", () => { + it.effect("serializes concurrent deliveries of the same request", () => { + const firstPlanStarted = Deferred.makeUnsafe(); + const releaseFirstPlan = Deferred.makeUnsafe(); + const t3Calls: Array = []; + const acknowledgements: Array = []; + let planCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => + Effect.gen(function* () { + planCalls += 1; + t3Calls.push("planT3Work"); + + if (planCalls === 1) { + yield* Deferred.succeed(firstPlanStarted, undefined); + yield* Deferred.await(releaseFirstPlan); + } + + return coordinates; + }), + getThreadStatus: () => + Effect.sync(() => { + t3Calls.push("getThreadStatus"); + return { thread: "missing" as const }; + }), + provisionThread: () => + Effect.sync(() => { + t3Calls.push("provisionThread"); + }), + getTurnStatus: () => + Effect.sync(() => { + t3Calls.push("getTurnStatus"); + return { turn: "missing" as const }; + }), + startTurn: () => + Effect.sync(() => { + t3Calls.push("startTurn"); + }), + }, + adapter: { + acknowledge: (state) => + Effect.sync(() => { + acknowledgements.push(state); + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const first = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + // The first request now holds the source lock inside planT3Work. + yield* Deferred.await(firstPlanStarted); + + const second = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(planCalls).toBe(1); + expect(second.pollUnsafe()).toBeUndefined(); + expect(yield* repository.findBySourceUri(request.sourceUri)).toBeNull(); + + yield* Deferred.succeed(releaseFirstPlan, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + const expected = toThreadCreated(makeRequestClaimed(request, coordinates)); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + expect(acknowledgements).toEqual([expected]); + expect(t3Calls).toEqual([ + "planT3Work", + "getThreadStatus", + "provisionThread", + "getTurnStatus", + "startTurn", + ]); + }), + ); + }); + + it.effect("lets a queued delivery claim after the first fails before persistence", () => { + const firstPlanStarted = Deferred.makeUnsafe(); + const releaseFirstPlan = Deferred.makeUnsafe(); + const acknowledgements: Array = []; + let planCalls = 0; + let provisionCalls = 0; + let startTurnCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => + Effect.gen(function* () { + planCalls += 1; + + if (planCalls === 1) { + yield* Deferred.succeed(firstPlanStarted, undefined); + yield* Deferred.await(releaseFirstPlan); + return yield* new T3GatewayError({ + reason: "The first planning attempt failed", + cause: "test failure", + }); + } + + return coordinates; + }), + getThreadStatus: () => Effect.succeed({ thread: "missing" }), + provisionThread: () => + Effect.sync(() => { + provisionCalls += 1; + }), + getTurnStatus: () => Effect.succeed({ turn: "missing" }), + startTurn: () => + Effect.sync(() => { + startTurnCalls += 1; + }), + }, + adapter: { + acknowledge: (state) => + Effect.sync(() => { + acknowledgements.push(state); + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const first = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(firstPlanStarted); + + const second = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(planCalls).toBe(1); + expect(second.pollUnsafe()).toBeUndefined(); + expect(yield* repository.findBySourceUri(request.sourceUri)).toBeNull(); + + yield* Deferred.succeed(releaseFirstPlan, undefined); + + expect((yield* Fiber.await(first))._tag).toBe("Failure"); + yield* Fiber.join(second); + + const expected = toThreadCreated(makeRequestClaimed(request, coordinates)); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + expect(acknowledgements).toEqual([expected]); + expect(planCalls).toBe(2); + expect(provisionCalls).toBe(1); + expect(startTurnCalls).toBe(1); + }), + ); + }); + + it.effect("retains a failed claim for later recovery and ignores its queued redelivery", () => { + const threadStatusStarted = Deferred.makeUnsafe(); + const releaseThreadStatus = Deferred.makeUnsafe(); + const recoveredTurnStarted = Deferred.makeUnsafe(); + let planCalls = 0; + let threadStatusCalls = 0; + let provisionCalls = 0; + let startTurnCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => + Effect.sync(() => { + planCalls += 1; + return coordinates; + }), + getThreadStatus: () => + Effect.gen(function* () { + threadStatusCalls += 1; + + if (threadStatusCalls === 1) { + yield* Deferred.succeed(threadStatusStarted, undefined); + yield* Deferred.await(releaseThreadStatus); + return yield* new T3GatewayError({ + reason: "Failed after persisting the claim", + cause: "test failure", + }); + } + + return { thread: "missing" as const }; + }), + provisionThread: () => + Effect.sync(() => { + provisionCalls += 1; + }), + getTurnStatus: () => Effect.succeed({ turn: "missing" }), + startTurn: () => + Effect.gen(function* () { + startTurnCalls += 1; + yield* Deferred.succeed(recoveredTurnStarted, undefined); + }), + }, + adapter: { + acknowledge: () => Effect.void, + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const first = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(threadStatusStarted); + + const claimed = makeRequestClaimed(request, coordinates); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(claimed); + + const second = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(second.pollUnsafe()).toBeUndefined(); + expect(planCalls).toBe(1); + expect(threadStatusCalls).toBe(1); + + yield* Deferred.succeed(releaseThreadStatus, undefined); + + expect((yield* Fiber.await(first))._tag).toBe("Failure"); + yield* Fiber.join(second); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(claimed); + expect(planCalls).toBe(1); + expect(threadStatusCalls).toBe(1); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(recoveredTurnStarted); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual( + toThreadCreated(claimed), + ); + expect(planCalls).toBe(1); + expect(threadStatusCalls).toBe(2); + expect(provisionCalls).toBe(1); + expect(startTurnCalls).toBe(1); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("releases the source lock when its holder is interrupted", () => { + const firstPlanStarted = Deferred.makeUnsafe(); + const keepFirstPlanBlocked = Deferred.makeUnsafe(); + const acknowledgements: Array = []; + let planCalls = 0; + let provisionCalls = 0; + let startTurnCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => + Effect.gen(function* () { + planCalls += 1; + + if (planCalls === 1) { + yield* Deferred.succeed(firstPlanStarted, undefined); + yield* Deferred.await(keepFirstPlanBlocked); + } + + return coordinates; + }), + getThreadStatus: () => Effect.succeed({ thread: "missing" }), + provisionThread: () => + Effect.sync(() => { + provisionCalls += 1; + }), + getTurnStatus: () => Effect.succeed({ turn: "missing" }), + startTurn: () => + Effect.sync(() => { + startTurnCalls += 1; + }), + }, + adapter: { + acknowledge: (state) => + Effect.sync(() => { + acknowledgements.push(state); + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const first = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(firstPlanStarted); + + const second = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(planCalls).toBe(1); + expect(second.pollUnsafe()).toBeUndefined(); + expect(yield* repository.findBySourceUri(request.sourceUri)).toBeNull(); + + yield* Fiber.interrupt(first); + expect((yield* Fiber.await(first))._tag).toBe("Failure"); + yield* Fiber.join(second); + + const expected = toThreadCreated(makeRequestClaimed(request, coordinates)); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + expect(acknowledgements).toEqual([expected]); + expect(planCalls).toBe(2); + expect(provisionCalls).toBe(1); + expect(startTurnCalls).toBe(1); + }), + ); + }); + + it.effect("interrupting a queued delivery preserves the lock for later deliveries", () => { + const firstPlanStarted = Deferred.makeUnsafe(); + const releaseFirstPlan = Deferred.makeUnsafe(); + const t3Calls: Array = []; + const acknowledgements: Array = []; + let planCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => + Effect.gen(function* () { + planCalls += 1; + t3Calls.push("planT3Work"); + + if (planCalls === 1) { + yield* Deferred.succeed(firstPlanStarted, undefined); + yield* Deferred.await(releaseFirstPlan); + } + + return coordinates; + }), + getThreadStatus: () => + Effect.sync(() => { + t3Calls.push("getThreadStatus"); + return { thread: "missing" as const }; + }), + provisionThread: () => + Effect.sync(() => { + t3Calls.push("provisionThread"); + }), + getTurnStatus: () => + Effect.sync(() => { + t3Calls.push("getTurnStatus"); + return { turn: "missing" as const }; + }), + startTurn: () => + Effect.sync(() => { + t3Calls.push("startTurn"); + }), + }, + adapter: { + acknowledge: (state) => + Effect.sync(() => { + acknowledgements.push(state); + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const first = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(firstPlanStarted); + + const interruptedWaiter = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(planCalls).toBe(1); + expect(interruptedWaiter.pollUnsafe()).toBeUndefined(); + + yield* Fiber.interrupt(interruptedWaiter); + expect((yield* Fiber.await(interruptedWaiter))._tag).toBe("Failure"); + expect(first.pollUnsafe()).toBeUndefined(); + expect(yield* repository.findBySourceUri(request.sourceUri)).toBeNull(); + + const later = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(planCalls).toBe(1); + expect(later.pollUnsafe()).toBeUndefined(); + + yield* Deferred.succeed(releaseFirstPlan, undefined); + yield* Fiber.join(first); + yield* Fiber.join(later); + + const expected = toThreadCreated(makeRequestClaimed(request, coordinates)); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + expect(acknowledgements).toEqual([expected]); + expect(t3Calls).toEqual([ + "planT3Work", + "getThreadStatus", + "provisionThread", + "getTurnStatus", + "startTurn", + ]); + }), + ); + }); + + it.effect("allows different requests to proceed concurrently", () => { + const firstPlanStarted = Deferred.makeUnsafe(); + const releaseFirstPlan = Deferred.makeUnsafe(); + const secondPlanStarted = Deferred.makeUnsafe(); + const acknowledgements: Array = []; + let planCalls = 0; + let provisionCalls = 0; + let startTurnCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => + Effect.gen(function* () { + planCalls += 1; + + if (planCalls === 1) { + yield* Deferred.succeed(firstPlanStarted, undefined); + yield* Deferred.await(releaseFirstPlan); + return coordinates; + } + + yield* Deferred.succeed(secondPlanStarted, undefined); + return secondCoordinates; + }), + getThreadStatus: () => Effect.succeed({ thread: "missing" }), + provisionThread: () => + Effect.sync(() => { + provisionCalls += 1; + }), + getTurnStatus: () => Effect.succeed({ turn: "missing" }), + startTurn: () => + Effect.sync(() => { + startTurnCalls += 1; + }), + }, + adapter: { + acknowledge: (state) => + Effect.sync(() => { + acknowledgements.push(state); + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const first = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(firstPlanStarted); + + const second = yield* processor + .process(secondRequest, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(yield* Deferred.isDone(secondPlanStarted)).toBe(true); + yield* Fiber.join(second); + + const expectedSecond = toThreadCreated( + makeRequestClaimed(secondRequest, secondCoordinates), + ); + + expect(first.pollUnsafe()).toBeUndefined(); + expect(yield* repository.findBySourceUri(request.sourceUri)).toBeNull(); + expect(yield* repository.findBySourceUri(secondRequest.sourceUri)).toEqual( + expectedSecond, + ); + expect(acknowledgements).toEqual([expectedSecond]); + expect(planCalls).toBe(2); + expect(provisionCalls).toBe(1); + expect(startTurnCalls).toBe(1); + + yield* Deferred.succeed(releaseFirstPlan, undefined); + yield* Fiber.join(first); + + const expectedFirst = toThreadCreated(makeRequestClaimed(request, coordinates)); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expectedFirst); + expect(acknowledgements).toEqual([expectedSecond, expectedFirst]); + expect(planCalls).toBe(2); + expect(provisionCalls).toBe(2); + expect(startTurnCalls).toBe(2); + }), + ); + }); + }); + + describe("run", () => { + it.effect("resumes non-terminal exchanges when run starts", () => { + const replyPosted = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Recovered reply", + }; + const replySourceUri = "test://reply/recovered"; + const postedReplies: Array = []; + + return withTestProcessor( + { + t3: { + getTurnStatus: () => + Effect.succeed({ + turn: "completed", + reply, + }), + }, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: (state) => + Effect.gen(function* () { + postedReplies.push(state); + yield* Deferred.succeed(replyPosted, undefined); + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + yield* repository.upsert(threadCreated); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(replyPosted); + + const replyPending = toReplyPending(threadCreated, reply); + const expected = toReplyPosted(replyPending, replySourceUri); + + expect(postedReplies).toEqual([replyPending]); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("routes thread activity only for stored exchanges", () => { + const unknownActivity = Deferred.makeUnsafe(); + const storedActivity = Deferred.makeUnsafe(); + const replyPosted = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply after thread activity", + }; + const replySourceUri = "test://reply/activity"; + const observedThreads: Array = []; + + return withTestProcessor( + { + t3: { + threadActivity: Stream.concat( + Stream.fromEffect(Deferred.await(unknownActivity)), + Stream.fromEffect(Deferred.await(storedActivity)), + ), + getTurnStatus: (state) => + Effect.sync(() => { + observedThreads.push(state.t3.threadId); + return { + turn: "completed" as const, + reply, + }; + }), + }, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: () => + Effect.gen(function* () { + yield* Deferred.succeed(replyPosted, undefined); + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + // Startup recovery has already observed the empty repository. + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + yield* repository.upsert(threadCreated); + + yield* Deferred.succeed(unknownActivity, ThreadId.make("unknown-thread")); + yield* Deferred.succeed(storedActivity, coordinates.threadId); + yield* Deferred.await(replyPosted); + + const expected = toReplyPosted(toReplyPending(threadCreated, reply), replySourceUri); + + expect(observedThreads).toEqual([coordinates.threadId]); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("serializes thread activity with a redelivered request", () => { + const threadActivity = Deferred.makeUnsafe(); + const turnStatusStarted = Deferred.makeUnsafe(); + const releaseTurnStatus = Deferred.makeUnsafe(); + const replyPosted = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply from thread activity", + }; + const replySourceUri = "test://reply/activity-race"; + const postedReplies: Array = []; + + return withTestProcessor( + { + t3: { + threadActivity: Stream.fromEffect(Deferred.await(threadActivity)), + getTurnStatus: () => + Effect.gen(function* () { + yield* Deferred.succeed(turnStatusStarted, undefined); + yield* Deferred.await(releaseTurnStatus); + return { + turn: "completed" as const, + reply, + }; + }), + }, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: (state) => + Effect.gen(function* () { + postedReplies.push(state); + yield* Deferred.succeed(replyPosted, undefined); + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + yield* repository.upsert(threadCreated); + yield* Deferred.succeed(threadActivity, coordinates.threadId); + yield* Deferred.await(turnStatusStarted); + + const redelivery = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(redelivery.pollUnsafe()).toBeUndefined(); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(threadCreated); + + yield* Deferred.succeed(releaseTurnStatus, undefined); + yield* Deferred.await(replyPosted); + yield* Fiber.join(redelivery); + + const replyPending = toReplyPending(threadCreated, reply); + const expected = toReplyPosted(replyPending, replySourceUri); + + expect(postedReplies).toEqual([replyPending]); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("serializes startup recovery with a redelivered request", () => { + const turnStatusStarted = Deferred.makeUnsafe(); + const releaseTurnStatus = Deferred.makeUnsafe(); + const replyPosted = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply from startup recovery", + }; + const replySourceUri = "test://reply/recovery-race"; + const postedReplies: Array = []; + + return withTestProcessor( + { + t3: { + getTurnStatus: () => + Effect.gen(function* () { + yield* Deferred.succeed(turnStatusStarted, undefined); + yield* Deferred.await(releaseTurnStatus); + return { + turn: "completed" as const, + reply, + }; + }), + }, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: (state) => + Effect.gen(function* () { + postedReplies.push(state); + yield* Deferred.succeed(replyPosted, undefined); + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + yield* repository.upsert(threadCreated); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(turnStatusStarted); + + const redelivery = yield* processor + .process(request, target) + .pipe(Effect.forkChild({ startImmediately: true })); + + expect(redelivery.pollUnsafe()).toBeUndefined(); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(threadCreated); + + yield* Deferred.succeed(releaseTurnStatus, undefined); + yield* Deferred.await(replyPosted); + yield* Fiber.join(redelivery); + + const replyPending = toReplyPending(threadCreated, reply); + const expected = toReplyPosted(replyPending, replySourceUri); + + expect(postedReplies).toEqual([replyPending]); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("retries a transient turn-status failure on later thread activity", () => { + const firstStatusFinished = Deferred.makeUnsafe(); + const threadActivity = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply after retrying turn status", + }; + const replySourceUri = "test://reply/turn-status-retry"; + let statusCalls = 0; + + return withTestProcessor( + { + t3: { + threadActivity: Stream.fromEffect(Deferred.await(threadActivity)), + getTurnStatus: () => { + statusCalls += 1; + + return statusCalls === 1 + ? Effect.fail( + new T3GatewayError({ + reason: "Turn status temporarily unavailable", + cause: "test failure", + }), + ).pipe(Effect.ensuring(Deferred.succeed(firstStatusFinished, undefined))) + : Effect.succeed({ turn: "completed" as const, reply }); + }, + }, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: () => Effect.succeed(replySourceUri), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + yield* repository.upsert(threadCreated); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(firstStatusFinished); + yield* Deferred.succeed(threadActivity, coordinates.threadId); + + const posted = yield* waitForStoredState( + repository, + request.sourceUri, + (state): state is ReplyPosted => state.tag === "reply-posted", + ); + + expect(statusCalls).toBe(2); + expect(posted).toEqual( + toReplyPosted(toReplyPending(threadCreated, reply), replySourceUri), + ); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("continues startup recovery after one exchange fails", () => { + const reply = { + type: "answer" as const, + text: "Reply recovered after another exchange failed", + }; + const replySourceUri = "test://reply/recovery-continued"; + let failingSourceUri = ""; + let successfulSourceUri = ""; + const postedSources: Array = []; + + return withTestProcessor( + { + t3: {}, + adapter: { + findPostedReply: (state) => + state.sourceUri === failingSourceUri + ? Effect.fail( + new AdapterError({ + reason: "Recovery failed for this exchange", + cause: "test failure", + }), + ) + : Effect.succeed(null), + postReply: (state) => + Effect.sync(() => { + postedSources.push(state.sourceUri); + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const firstPending = toReplyPending( + toThreadCreated(makeRequestClaimed(request, coordinates)), + reply, + ); + const secondPending = toReplyPending( + toThreadCreated(makeRequestClaimed(secondRequest, secondCoordinates)), + reply, + ); + yield* repository.upsert(firstPending); + yield* repository.upsert(secondPending); + + const recoveryOrder = yield* repository.findNonTerminalExchanges; + failingSourceUri = recoveryOrder[0]!.sourceUri; + successfulSourceUri = recoveryOrder[1]!.sourceUri; + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + const posted = yield* waitForStoredState( + repository, + successfulSourceUri, + (state): state is ReplyPosted => state.tag === "reply-posted", + ); + const expectedFailing = + failingSourceUri === firstPending.sourceUri ? firstPending : secondPending; + const expectedSuccessful = + successfulSourceUri === firstPending.sourceUri ? firstPending : secondPending; + + expect(yield* repository.findBySourceUri(failingSourceUri)).toEqual(expectedFailing); + expect(posted).toEqual(toReplyPosted(expectedSuccessful, replySourceUri)); + expect(postedSources).toEqual([successfulSourceUri]); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("continues processing thread activity after one event fails", () => { + const startupStatusRead = Deferred.makeUnsafe(); + const firstActivity = Deferred.makeUnsafe(); + const firstActivityFinished = Deferred.makeUnsafe(); + const secondActivity = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply from the later activity event", + }; + const replySourceUri = "test://reply/later-activity"; + let firstExchangeStatusCalls = 0; + let secondExchangeStatusCalls = 0; + + return withTestProcessor( + { + t3: { + threadActivity: Stream.concat( + Stream.fromEffect(Deferred.await(firstActivity)), + Stream.fromEffect(Deferred.await(secondActivity)), + ), + getTurnStatus: (state) => { + if (state.sourceUri === request.sourceUri) { + firstExchangeStatusCalls += 1; + + if (firstExchangeStatusCalls === 1) { + return Deferred.succeed(startupStatusRead, undefined).pipe( + Effect.as({ turn: "active" as const }), + ); + } + + return Effect.fail( + new T3GatewayError({ + reason: "This activity event could not be processed", + cause: "test failure", + }), + ).pipe(Effect.ensuring(Deferred.succeed(firstActivityFinished, undefined))); + } + + secondExchangeStatusCalls += 1; + return Effect.succeed({ turn: "completed" as const, reply }); + }, + }, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: () => Effect.succeed(replySourceUri), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const firstThreadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + yield* repository.upsert(firstThreadCreated); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(startupStatusRead); + + const secondThreadCreated = toThreadCreated( + makeRequestClaimed(secondRequest, secondCoordinates), + ); + yield* repository.upsert(secondThreadCreated); + yield* Deferred.succeed(firstActivity, coordinates.threadId); + yield* Deferred.await(firstActivityFinished); + yield* Deferred.succeed(secondActivity, secondCoordinates.threadId); + + const posted = yield* waitForStoredState( + repository, + secondRequest.sourceUri, + (state): state is ReplyPosted => state.tag === "reply-posted", + ); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual( + firstThreadCreated, + ); + expect(firstExchangeStatusCalls).toBe(2); + expect(secondExchangeStatusCalls).toBe(1); + expect(posted).toEqual( + toReplyPosted(toReplyPending(secondThreadCreated, reply), replySourceUri), + ); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("subscribes to thread activity before startup recovery finishes", () => { + const recoveryStarted = Deferred.makeUnsafe(); + const releaseRecovery = Deferred.makeUnsafe(); + const threadActivity = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply posted while startup recovery is blocked", + }; + const replySourceUri = "test://reply/during-recovery"; + + return withTestProcessor( + { + t3: { + threadActivity: Stream.fromEffect(Deferred.await(threadActivity)), + getTurnStatus: (state) => + state.sourceUri === request.sourceUri + ? Effect.gen(function* () { + yield* Deferred.succeed(recoveryStarted, undefined); + yield* Deferred.await(releaseRecovery); + return { turn: "active" as const }; + }) + : Effect.succeed({ turn: "completed" as const, reply }), + }, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: () => Effect.succeed(replySourceUri), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const recovering = toThreadCreated(makeRequestClaimed(request, coordinates)); + yield* repository.upsert(recovering); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(recoveryStarted); + + const activeDuringRecovery = toThreadCreated( + makeRequestClaimed(secondRequest, secondCoordinates), + ); + yield* repository.upsert(activeDuringRecovery); + yield* Deferred.succeed(threadActivity, secondCoordinates.threadId); + + const posted = yield* waitForStoredState( + repository, + secondRequest.sourceUri, + (state): state is ReplyPosted => state.tag === "reply-posted", + ); + + expect(yield* Deferred.isDone(releaseRecovery)).toBe(false); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(recovering); + expect(posted).toEqual( + toReplyPosted(toReplyPending(activeDuringRecovery, reply), replySourceUri), + ); + + yield* Deferred.succeed(releaseRecovery, undefined); + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("posts once when startup recovery races with thread activity", () => { + const threadActivity = Deferred.makeUnsafe(); + const activityHandled = Deferred.makeUnsafe(); + const turnStatusStarted = Deferred.makeUnsafe(); + const releaseTurnStatus = Deferred.makeUnsafe(); + const secondTurnStatusStarted = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply from the recovery and activity race", + }; + const replySourceUri = "test://reply/recovery-activity-race"; + let turnStatusCalls = 0; + let postCalls = 0; + + return withTestProcessor( + { + t3: { + threadActivity: Stream.concat( + Stream.fromEffect(Deferred.await(threadActivity)), + Stream.fromEffect( + Deferred.succeed(activityHandled, undefined).pipe( + Effect.as(ThreadId.make("activity-handled")), + ), + ), + ), + getTurnStatus: () => + Effect.gen(function* () { + turnStatusCalls += 1; + + if (turnStatusCalls === 1) { + yield* Deferred.succeed(turnStatusStarted, undefined); + yield* Deferred.await(releaseTurnStatus); + } else { + yield* Deferred.succeed(secondTurnStatusStarted, undefined); + } + + return { turn: "completed" as const, reply }; + }), + }, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: () => + Effect.sync(() => { + postCalls += 1; + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + yield* repository.upsert(threadCreated); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(turnStatusStarted); + yield* Deferred.succeed(threadActivity, coordinates.threadId); + yield* Effect.yieldNow; + + expect(yield* Deferred.isDone(secondTurnStatusStarted)).toBe(false); + + yield* Deferred.succeed(releaseTurnStatus, undefined); + yield* Deferred.await(activityHandled); + + const posted = yield* waitForStoredState( + repository, + request.sourceUri, + (state): state is ReplyPosted => state.tag === "reply-posted", + ); + + expect(turnStatusCalls).toBe(1); + expect(postCalls).toBe(1); + expect(posted).toEqual( + toReplyPosted(toReplyPending(threadCreated, reply), replySourceUri), + ); + + yield* Fiber.interrupt(run); + }), + ); + }); + }); + + describe("reply delivery", () => { + it.effect("retries a transient reply-posting failure during later recovery", () => { + const firstPostFinished = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply after a transient posting failure", + }; + const replySourceUri = "test://reply/retried-post"; + let postCalls = 0; + + return withTestProcessor( + { + t3: {}, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: () => { + postCalls += 1; + + return postCalls === 1 + ? Effect.fail( + new AdapterError({ + reason: "Reply posting temporarily failed", + cause: "test failure", + }), + ).pipe(Effect.ensuring(Deferred.succeed(firstPostFinished, undefined))) + : Effect.succeed(replySourceUri); + }, + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const pending = toReplyPending( + toThreadCreated(makeRequestClaimed(request, coordinates)), + reply, + ); + yield* repository.upsert(pending); + + const firstRun = yield* processor.run.pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.await(firstPostFinished); + yield* Fiber.interrupt(firstRun); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(pending); + + const secondRun = yield* processor.run.pipe( + Effect.forkChild({ startImmediately: true }), + ); + const posted = yield* waitForStoredState( + repository, + request.sourceUri, + (state): state is ReplyPosted => state.tag === "reply-posted", + ); + + expect(postCalls).toBe(2); + expect(posted).toEqual(toReplyPosted(pending, replySourceUri)); + + yield* Fiber.interrupt(secondRun); + }), + ); + }); + + it.effect("retries reply discovery before posting during later recovery", () => { + const firstDiscoveryFinished = Deferred.makeUnsafe(); + const reply = { + type: "answer" as const, + text: "Reply after a transient discovery failure", + }; + const replySourceUri = "test://reply/retried-discovery"; + let findCalls = 0; + let postCalls = 0; + + return withTestProcessor( + { + t3: {}, + adapter: { + findPostedReply: () => { + findCalls += 1; + + return findCalls === 1 + ? Effect.fail( + new AdapterError({ + reason: "Reply discovery temporarily failed", + cause: "test failure", + }), + ).pipe(Effect.ensuring(Deferred.succeed(firstDiscoveryFinished, undefined))) + : Effect.succeed(null); + }, + postReply: () => + Effect.sync(() => { + postCalls += 1; + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const pending = toReplyPending( + toThreadCreated(makeRequestClaimed(request, coordinates)), + reply, + ); + yield* repository.upsert(pending); + + const firstRun = yield* processor.run.pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.await(firstDiscoveryFinished); + yield* Fiber.interrupt(firstRun); + + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(pending); + expect(postCalls).toBe(0); + + const secondRun = yield* processor.run.pipe( + Effect.forkChild({ startImmediately: true }), + ); + const posted = yield* waitForStoredState( + repository, + request.sourceUri, + (state): state is ReplyPosted => state.tag === "reply-posted", + ); + + expect(findCalls).toBe(2); + expect(postCalls).toBe(1); + expect(posted).toEqual(toReplyPosted(pending, replySourceUri)); + + yield* Fiber.interrupt(secondRun); + }), + ); + }); + + it.effect("records a reply already found on the platform without posting it again", () => { + const reply = { + type: "answer" as const, + text: "Already delivered", + }; + const discoveredReplySourceUri = "test://reply/already-posted"; + const findCalls: Array = []; + let postCalls = 0; + + return withTestProcessor( + { + t3: {}, + adapter: { + findPostedReply: (state) => + Effect.sync(() => { + findCalls.push(state); + return discoveredReplySourceUri; + }), + postReply: () => + Effect.sync(() => { + postCalls += 1; + return "test://reply/unexpected"; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + const replyPending = toReplyPending(threadCreated, reply); + yield* repository.upsert(replyPending); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + const expected = toReplyPosted(replyPending, discoveredReplySourceUri); + + expect(findCalls).toEqual([replyPending]); + expect(postCalls).toBe(0); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("records a definitively rejected reply as undeliverable", () => { + const reply = { + type: "answer" as const, + text: "Reply that cannot be delivered", + }; + const rejectionCause = { + message: "The originating discussion was deleted", + }; + const postCalls: Array = []; + + return withTestProcessor( + { + t3: {}, + adapter: { + findPostedReply: () => Effect.succeed(null), + postReply: (state) => + Effect.gen(function* () { + postCalls.push(state); + return yield* new ReplyRejected({ cause: rejectionCause }); + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + const replyPending = toReplyPending(threadCreated, reply); + yield* repository.upsert(replyPending); + + const run = yield* processor.run.pipe(Effect.forkChild({ startImmediately: true })); + + const expected = toUndeliverable(replyPending, rejectionCause); + + expect(postCalls).toEqual([replyPending]); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + + yield* Fiber.interrupt(run); + }), + ); + }); + + it.effect("delivers a failure reply when T3 rejects thread provisioning", () => { + const rejectionReason = "T3 cannot provision this request"; + const rejectionCause = { + message: "The selected project no longer exists", + }; + const replySourceUri = "test://reply/provisioning-failure"; + const postedReplies: Array = []; + let provisionCalls = 0; + let acknowledgementCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => Effect.succeed(coordinates), + getThreadStatus: () => Effect.succeed({ thread: "missing" }), + provisionThread: () => + Effect.gen(function* () { + provisionCalls += 1; + return yield* new T3Rejected({ + reason: rejectionReason, + cause: rejectionCause, + }); + }), + }, + adapter: { + acknowledge: () => + Effect.sync(() => { + acknowledgementCalls += 1; + }), + findPostedReply: () => Effect.succeed(null), + postReply: (state) => + Effect.sync(() => { + postedReplies.push(state); + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + yield* processor.process(request, target); + + const claimed = makeRequestClaimed(request, coordinates); + const failureReply = { + type: "failure" as const, + text: rejectionReason, + cause: rejectionCause, + }; + const replyPending = toReplyPending(claimed, failureReply); + const expected = toReplyPosted(replyPending, replySourceUri); + + expect(provisionCalls).toBe(1); + expect(acknowledgementCalls).toBe(0); + expect(postedReplies).toEqual([replyPending]); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + }), + ); + }); + + it.effect("delivers a failure reply when T3 rejects turn start", () => { + const rejectionReason = "T3 cannot start the turn"; + const rejectionCause = { + message: "The configured provider is unavailable", + }; + const replySourceUri = "test://reply/turn-start-failure"; + const acknowledgements: Array = []; + const postedReplies: Array = []; + let startTurnCalls = 0; + + return withTestProcessor( + { + t3: { + planT3Work: () => Effect.succeed(coordinates), + getThreadStatus: () => Effect.succeed({ thread: "present" }), + getTurnStatus: () => Effect.succeed({ turn: "missing" }), + startTurn: () => + Effect.gen(function* () { + startTurnCalls += 1; + return yield* new T3Rejected({ + reason: rejectionReason, + cause: rejectionCause, + }); + }), + }, + adapter: { + acknowledge: (state) => + Effect.sync(() => { + acknowledgements.push(state); + }), + findPostedReply: () => Effect.succeed(null), + postReply: (state) => + Effect.sync(() => { + postedReplies.push(state); + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + yield* processor.process(request, target); + + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + const failureReply = { + type: "failure" as const, + text: rejectionReason, + cause: rejectionCause, + }; + const replyPending = toReplyPending(threadCreated, failureReply); + const expected = toReplyPosted(replyPending, replySourceUri); + + expect(startTurnCalls).toBe(1); + expect(acknowledgements).toEqual([threadCreated]); + expect(postedReplies).toEqual([replyPending]); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + }), + ); + }); + + it.effect("posts a completed T3 reply", () => { + const reply = { + type: "answer" as const, + text: "The bug is fixed.", + }; + const replySourceUri = "test://reply/1"; + const postedReplies: Array = []; + + return withTestProcessor( + { + t3: { + planT3Work: () => Effect.succeed(coordinates), + getThreadStatus: () => Effect.succeed({ thread: "present" }), + getTurnStatus: () => + Effect.succeed({ + turn: "completed", + reply, + }), + }, + adapter: { + acknowledge: () => Effect.void, + findPostedReply: () => Effect.succeed(null), + postReply: (state) => + Effect.sync(() => { + postedReplies.push(state); + return replySourceUri; + }), + }, + }, + ({ processor, repository }) => + Effect.gen(function* () { + yield* processor.process(request, target); + + const threadCreated = toThreadCreated(makeRequestClaimed(request, coordinates)); + const replyPending = toReplyPending(threadCreated, reply); + const expected = toReplyPosted(replyPending, replySourceUri); + + expect(postedReplies).toEqual([replyPending]); + expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); + }), + ); + }); + }); +}); diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index d0393cce8128..0a4fed936092 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -348,7 +348,7 @@ export const makeNTBSProcessor: Effect.Effect Effect.Effect; + readonly planT3Work: ( + projectId: ProjectId, + baseRef: string, + ) => Effect.Effect; readonly getThreadStatus: ( state: NTBS.RequestClaimed, diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index f07185b7656b..b59bf19fe249 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -123,3 +123,27 @@ Model → contract → orchestration; each phase leaves the previous one settled 3. **Processor (orchestration).** Collapse `process` / `recoverThread` / `processT3Event` into `process` plus one internal reconciler; the exposed surface stays `process` and `run`. `process` claims, then provisions and starts the turn. `run` calls the reconciler — load → observe → decide → execute → persist — on startup and on T3 events. Serialize per `sourceUri`; the outcome lock and `inFlightRequests` collapse into that. Crash-window tests drive the real loop against the real in-memory repository, with the adapter and T3 gateway faked. 4. **Jira port** (ntbs-plan step 3) as the first real adapter on the settled contract, replacing the legacy bridge path. + +## Remaining processor tests + +The processor tests are grouped by responsibility: `process`, source serialization, `run`, and reply delivery. + +Lifecycle and retry behavior: + +- [x] A transient `postReply` failure leaves the exchange in `ReplyPending`; a later recovery retries and reaches `ReplyPosted`. +- [x] A transient `findPostedReply` failure leaves the exchange in `ReplyPending`; a later recovery repeats discovery before posting. +- [x] A failed acknowledgement is best-effort: processing still persists `ThreadCreated` and starts the turn. +- [x] An active turn leaves `ThreadCreated` unchanged and performs no delivery work. +- [x] A transient `provisionThread` failure leaves `RequestClaimed`; later recovery provisions the thread successfully. +- [x] A transient `getTurnStatus` failure leaves `ThreadCreated`; later activity retries and records the completed reply. +- [x] A transient `startTurn` failure leaves `ThreadCreated`; later recovery retries the start. +- [x] Extend the post-persistence failure test to prove the retained `RequestClaimed` can later be resumed by `run`. + +`run` robustness: + +- [x] One exchange failing during startup recovery does not prevent another exchange from advancing. +- [x] One thread-activity event failing does not stop later activity events from being processed. +- [x] Activity subscription starts before recovery: an event arriving while startup recovery is blocked is not missed. +- [x] Startup recovery racing with activity for the same exchange posts only one reply. + +After these cases, stop expanding the processor suite unless its contract changes. Do not add tests for every `NTBSProcessorError.reason` string, every lifecycle tag already covered by the pure model tests, repository behavior already covered by `ExchangeRepository.test.ts`, internal lock-map deletion with no observable behavior, or every `Reply` subtype that the processor handles identically. From 7788299f8cd7d6eeb3ca3f8449398cd8c1b81e4f Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 25 Aug 2026 10:18:26 +0200 Subject: [PATCH 104/110] feat: bump todo --- docs/planning/ntbs-todos.md | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/docs/planning/ntbs-todos.md b/docs/planning/ntbs-todos.md index b59bf19fe249..3b32bb5d6107 100644 --- a/docs/planning/ntbs-todos.md +++ b/docs/planning/ntbs-todos.md @@ -147,3 +147,98 @@ Lifecycle and retry behavior: - [x] Startup recovery racing with activity for the same exchange posts only one reply. After these cases, stop expanding the processor suite unless its contract changes. Do not add tests for every `NTBSProcessorError.reason` string, every lifecycle tag already covered by the pure model tests, repository behavior already covered by `ExchangeRepository.test.ts`, internal lock-map deletion with no observable behavior, or every `Reply` subtype that the processor handles identically. + +## T3 gateway implementation and tests + +There is currently no T3 gateway implementation or focused gateway test file: `t3gateway.ts` only defines the port. The processor tests mock that port and already cover how its contexts and errors drive the exchange lifecycle. The old processor tests exercise an obsolete adapter-owned design and are not useful gateway coverage. + +Gateway tests should build the real gateway with fake `ProjectionSnapshotQuery`, `ProjectionTurnRepository`, `GitWorkflowService`, `ProjectSetupScriptRunner`, `OrchestrationEngineService`, and deterministic clock/UUID services. They should assert the gateway result and its calls to those boundaries. They should not construct the real orchestration engine, run Git, or repeat processor recovery and persistence tests. + +### Settled gateway contracts + +Planning is branch-only. The platform selects a branch name; `planT3Work` fetches `origin` and pins that branch to the fetched commit. Store the selected branch and immutable commit separately from the new worktree branch: + +```ts +type T3WorkCoordinates = { + readonly projectId: ProjectId; + readonly baseBranchName: string; + readonly baseCommitSha: string; + readonly worktreeBranchName: string; + readonly threadId: ThreadId; + readonly userMessageId: MessageId; +}; +``` + +Worktree creation uses `baseCommitSha` as `refName`, `baseBranchName` as `baseRefName`, and `worktreeBranchName` as `newRefName`. A fetch or other Git/network failure is operational and becomes `T3GatewayError`, so the exchange remains retryable. A project that does not exist, or a branch that is absent after a successful fetch, is `T3Rejected`. + +Provisioning is complete only after the thread and worktree exist and the setup script has completed successfully. The T3 thread projection is the durable readiness marker: + +1. Create the thread first with the stored `threadId`, `worktreeBranchName`, and `worktreePath: null`. +2. Ensure the worktree exists for `worktreeBranchName`. +3. Run the setup script and wait for successful completion. +4. Only then dispatch `thread.meta.update` with the branch and final worktree path. +5. `getThreadStatus` reports `present` only when the thread exists and has a non-null `worktreePath`. An absent thread or a thread with a null path remains incomplete and causes provisioning to resume. + +Worktree recovery uses an exact, refreshed local-ref lookup for `worktreeBranchName`: reuse its live worktree, attach the existing branch when it has no worktree, recreate a stale/missing worktree, or create the branch from `baseCommitSha` when it does not exist. Put those Git-specific cases behind an `ensureWorktree` operation on `GitWorkflowService`; the gateway should not reproduce Git worktree bookkeeping. + +The existing `ProjectSetupScriptRunner.runForThread` only launches a terminal command. Add a blocking `runForThreadAndWait` operation, backed by `ProcessRunner` in the same style as `ProjectLifecycleScriptRunner`. It returns only after no script is needed or the setup command exits successfully; failure or timeout becomes `T3GatewayError`. + +Setup execution is at least once. If setup succeeds and the process dies before `thread.meta.update`, recovery reuses the worktree and runs setup again. Setup scripts must therefore be idempotent. Exactly-once setup would require another durable record and is outside v1. + +Error classification is fixed: + +- Missing project or missing selected branch after a successful fetch: `T3Rejected`. +- A worktree-branch collision inconsistent with the stored coordinates: `T3Rejected`. +- Projection/database, Git/network, setup, UUID, and orchestration persistence failures: `T3GatewayError`. +- Duplicate thread creation with the matching projected thread: successful recovery. +- Duplicate thread creation without the matching projection: `T3GatewayError`, because T3 state is inconsistent. +- Every gateway error preserves the original value as `cause`; classification never inspects arbitrary error text. + +Terminal reply conversion is fixed: + +- A completed turn with nonblank assistant text produces an answer containing the original text exactly. Trimming is used only to detect blank output. +- Missing or blank assistant output produces `"T3 completed without producing a reply."` with a serializable `missing-assistant-reply` cause containing the thread, user-message, and assistant-message IDs. +- An errored turn uses `session.lastError` or `"T3 failed while processing this request."`, with a serializable `turn-error` cause containing the thread ID, user-message ID, and recorded error. +- An interrupted turn produces `"T3 stopped processing this request."` with a serializable `turn-interrupted` cause containing the thread and user-message IDs. + +`threadActivity` emits only `thread.session-set` events, using `payload.threadId`, and preserves repeats. This relies on the orchestration invariant that a terminal session event is observed only after the final turn state and assistant output are readable from the projections. + +### Implementation prerequisites + +- [ ] Rename the coordinate fields to `baseBranchName`, `baseCommitSha`, and `worktreeBranchName`, and update their construction and consumers. +- [ ] Add `GitWorkflowService.ensureWorktree` with the exact-branch recovery behavior above. +- [ ] Add `ProjectSetupScriptRunner.runForThreadAndWait` with an explicit timeout and bounded diagnostic output. +- [ ] Implement the real `T3Gateway` constructor using the settled contracts before adding its focused tests. + +### Focused gateway test checklist + +`planT3Work`: + +- [ ] For an existing project and branch, fetch `origin`, resolve the remote branch once, and return its exact commit SHA alongside the selected branch, requested project, distinct minted thread/message IDs, and worktree branch derived from the thread ID. +- [ ] A fetch/network failure is `T3GatewayError`; a branch absent after a successful fetch is `T3Rejected`. Neither case returns unpinned coordinates or performs provisioning work. +- [ ] A missing project is `T3Rejected`; project-query and UUID failures are `T3GatewayError`. Every case retains its cause and performs no provisioning side effects. + +Thread observation and provisioning: + +- [ ] `getThreadStatus` queries the stored `threadId`: an absent thread or one with `worktreePath: null` maps to `{ thread: "missing" }`, while a non-null path maps to `{ thread: "present" }`. Projection failure becomes `T3GatewayError`. +- [ ] The normal `provisionThread` path dispatches `thread.create` with a null path, ensures a worktree from the stored branch/SHA pairing, waits for setup, and finally dispatches `thread.meta.update`. It uses the project's model selection or standard fallback, mints no replacement exchange IDs, and does not start a turn. +- [ ] A retry after `thread.create` reuses the matching incomplete thread instead of dispatching another. A conflicting duplicate without a matching projection fails as inconsistent T3 state. +- [ ] Cover `ensureWorktree` recovery through the gateway: reuse a live matching worktree, attach an existing branch without a worktree, recreate a stale/missing worktree, and create an absent branch from the stored SHA. A conflicting branch is `T3Rejected`. +- [ ] Setup failure or timeout returns `T3GatewayError` and leaves the thread projection incomplete. A later retry reuses the worktree, runs setup again, records the final path, and then reports the thread present. +- [ ] Failure to persist the final `thread.meta.update` remains retryable. Recovery reruns setup under the documented at-least-once rule and does not create another thread or worktree. + +Turn observation and start: + +- [ ] `startTurn` dispatches exactly one `thread.turn.start` using the stored `threadId`, `userMessageId`, snapshot, and attachments, with the agreed runtime/interaction defaults and a fresh command ID/timestamp. It does no project, Git, setup, or turn-status work. +- [ ] Classify representative permanent dispatch rejection as `T3Rejected` and operational dispatch failure as `T3GatewayError`, preserving each cause. The processor tests already cover what happens after either result. +- [ ] `getTurnStatus` selects the turn whose `pendingMessageId` equals the exchange's stored `userMessageId`, even when the thread contains newer or unrelated turns. +- [ ] No matching turn maps to `{ turn: "missing" }`; `pending` and `running` map to `{ turn: "active" }`. These cases must not load the heavier thread-detail snapshot. +- [ ] A completed turn with its matching assistant message preserves the original nonblank text exactly. Missing or blank output produces the fixed failure text and `missing-assistant-reply` cause. +- [ ] An errored turn produces the recorded error or fixed fallback with a `turn-error` cause; an interrupted turn produces the fixed cancellation text and `turn-interrupted` cause. +- [ ] Turn-list and terminal thread-detail query failures, or a terminal turn whose thread projection is missing, become `T3GatewayError` with the original diagnostic cause. + +Activity stream: + +- [ ] `threadActivity` emits `payload.threadId` for `thread.session-set`, filters every other orchestration event, and preserves repeated session events. The processor owns lookup, serialization, and terminal-state deduplication. + +Stop there unless the gateway contract grows. Do not retest UUID generation, the branch-name helper, Git command behavior, orchestration projection internals, setup-script internals, or processor state transitions; those belong to their existing modules and suites. From a2ed992f10dbf8e6a3ec7954366f472d8cc4bc7a Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 25 Aug 2026 10:54:57 +0200 Subject: [PATCH 105/110] chore: refine t3 gateway apis --- apps/server/src/ntbs/t3gateway.test.ts | 31 +++++++++++++++ apps/server/src/ntbs/t3gateway.ts | 53 ++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/ntbs/t3gateway.test.ts diff --git a/apps/server/src/ntbs/t3gateway.test.ts b/apps/server/src/ntbs/t3gateway.test.ts new file mode 100644 index 000000000000..fe8d411df19e --- /dev/null +++ b/apps/server/src/ntbs/t3gateway.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from "@effect/vitest"; + +describe("T3Gateway", () => { + describe("planT3Work", () => { + describe("successful planning", () => { + it.todo("pins the selected branch to the commit fetched from origin"); + + it.todo( + "returns the project, branch and commit with distinct thread and message IDs and a worktree branch derived from the thread ID", + ); + }); + + describe("rejected planning", () => { + it.todo("rejects a project that does not exist without performing provisioning work"); + + it.todo( + "rejects a selected branch that is absent after a successful fetch without performing provisioning work", + ); + }); + + describe("operational failures", () => { + it.todo("fails retryably when the project lookup fails"); + + it.todo("fails retryably when fetching origin fails"); + + it.todo("fails retryably when resolving the fetched branch fails operationally"); + + it.todo("fails retryably when the exchange IDs cannot be minted"); + }); + }); +}); diff --git a/apps/server/src/ntbs/t3gateway.ts b/apps/server/src/ntbs/t3gateway.ts index 114a0b6d6768..f3a820def23c 100644 --- a/apps/server/src/ntbs/t3gateway.ts +++ b/apps/server/src/ntbs/t3gateway.ts @@ -3,9 +3,9 @@ The T3 gateway module exposes the interface that the NTBS processor uses to comm with T3, similar to how adapter models the interaction with the external platform. */ -import { type ProjectId, ThreadId } from "@t3tools/contracts"; +import { MessageId, type ProjectId, ThreadId } from "@t3tools/contracts"; import type * as NTBS from "./exchange.ts"; -import { Context, Crypto, Data, Effect, Stream } from "effect"; +import { Context, Crypto, Data, Effect, Layer, Stream } from "effect"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; @@ -38,7 +38,7 @@ export class T3GatewayError extends Data.TaggedError("T3GatewayError")<{ cause: unknown; }> {} -type T3GatewayRequirements = +type _T3GatewayRequirements = /* Dispatches thread creation and turn-start commands. Provides the T3 event stream used to detect outcomes. @@ -101,3 +101,50 @@ export interface T3Gateway { } export const T3Gateway = Context.Service("t3code/ntbs/t3Gateway"); + +const T3GatewayLive: Effect.Effect = Effect.sync(function () { + const planT3Work = ( + projectId: ProjectId, + baseRef: string, + ): Effect.Effect => + Effect.succeed({ + projectId, + baseRefSha: baseRef + " sha", + threadId: ThreadId.make("some thread id"), + userMessageId: MessageId.make("userMessageId"), + branchName: baseRef + " branchName", + }); + + const getThreadStatus = ( + _state: NTBS.RequestClaimed, + ): Effect.Effect => + Effect.succeed({ + thread: "missing", + }); + + const getTurnStatus = ( + _state: NTBS.ThreadCreated, + ): Effect.Effect => + Effect.succeed({ + turn: "missing", + }); + + const startTurn = (_state: NTBS.ThreadCreated) => Effect.void; + + const threadActivity = Stream.never; + + const provisionThread = ( + _state: NTBS.RequestClaimed, + ): Effect.Effect => Effect.void; + + return { + startTurn, + getTurnStatus, + threadActivity, + planT3Work, + getThreadStatus, + provisionThread, + }; +}); + +export const t3GatewayLive = Layer.effect(T3Gateway, T3GatewayLive); From 19cb40cab8cfa41d9ed8e1f4d62620763917f477 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Tue, 25 Aug 2026 23:18:08 +0200 Subject: [PATCH 106/110] feat: implement planCoordinates --- .../src/ntbs/ExchangeRepository.test.ts | 5 +- apps/server/src/ntbs/exchange.test.ts | 9 +- apps/server/src/ntbs/exchange.ts | 18 +- apps/server/src/ntbs/processor-new.test.ts | 57 ++--- apps/server/src/ntbs/processor.ts | 2 +- apps/server/src/ntbs/t3gateway.test.ts | 2 +- apps/server/src/ntbs/t3gateway.ts | 202 +++++++++++++----- docs/planning/ntbs-questions.md | 3 + 8 files changed, 207 insertions(+), 91 deletions(-) create mode 100644 docs/planning/ntbs-questions.md diff --git a/apps/server/src/ntbs/ExchangeRepository.test.ts b/apps/server/src/ntbs/ExchangeRepository.test.ts index 203926630e98..0ca6891b1488 100644 --- a/apps/server/src/ntbs/ExchangeRepository.test.ts +++ b/apps/server/src/ntbs/ExchangeRepository.test.ts @@ -23,10 +23,11 @@ const makeExchange = (sourceUri: string, threadId: string) => }, { projectId: ProjectId.make("project"), - baseRefSha: "base-ref-sha", + startBranchName: "main", + startCommitSha: "start-commit-sha", threadId: ThreadId.make(threadId), userMessageId: MessageId.make(`message-${threadId}`), - branchName: `branch-${threadId}`, + worktreeBranchName: `branch-${threadId}`, }, ); diff --git a/apps/server/src/ntbs/exchange.test.ts b/apps/server/src/ntbs/exchange.test.ts index 51b25baa70f8..de8a5ac9dd17 100644 --- a/apps/server/src/ntbs/exchange.test.ts +++ b/apps/server/src/ntbs/exchange.test.ts @@ -12,7 +12,7 @@ import { type ReplyPosted, type Request, type RequestClaimed, - type T3WorkCoordinates, + type WorkCoordinates, } from "./exchange.ts"; import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; @@ -24,11 +24,12 @@ const request = { const coordinates = { projectId: ProjectId.make("projectId"), - baseRefSha: "baseRefSha", + startBranchName: "startBranchName", + startCommitSha: "startCommitSha", threadId: ThreadId.make("threadId"), userMessageId: MessageId.make("messageId"), - branchName: "branchName", -} satisfies T3WorkCoordinates; + worktreeBranchName: "worktreeBranchName", +} satisfies WorkCoordinates; const exchangeBase = { ...request, diff --git a/apps/server/src/ntbs/exchange.ts b/apps/server/src/ntbs/exchange.ts index 6e3f1cf9afe1..83756da79945 100644 --- a/apps/server/src/ntbs/exchange.ts +++ b/apps/server/src/ntbs/exchange.ts @@ -66,14 +66,17 @@ export type UndeliverableCause = { }; /** Stable identifiers and locations for an exchange's T3 work. */ -export type T3WorkCoordinates = { +export type WorkCoordinates = { readonly projectId: ProjectId; /** - * The commit used to create the T3 worktree. + * The branch this work starts from, and the commit it pointed at on `origin` + * when the request was claimed. * We keep the same SHA across retries so the request always runs against the - * code selected when it was claimed, even if the original branch moves later. + * code selected when it was claimed, even if the branch moves later. The name + * is recorded as the worktree's merge base for later diff and PR flows. */ - readonly baseRefSha: string; + readonly startBranchName: string; + readonly startCommitSha: string; // Planned while RequestClaimed; confirmed by ThreadCreated. readonly threadId: ThreadId; /** @@ -82,14 +85,15 @@ export type T3WorkCoordinates = { * receives other messages. */ readonly userMessageId: MessageId; - readonly branchName: string; + /** The branch minted for this request's worktree. */ + readonly worktreeBranchName: string; }; /** * The data every exchange carries, whatever state it has reached. */ export type ExchangeBase = Request & { - readonly t3: T3WorkCoordinates; + readonly t3: WorkCoordinates; }; /** @@ -178,7 +182,7 @@ export const isNonTerminal = (state: Exchange): state is NonTerminalExchange => export const makeRequestClaimed = ( request: Request, - coordinates: T3WorkCoordinates, + coordinates: WorkCoordinates, ): RequestClaimed => ({ ...request, t3: coordinates, diff --git a/apps/server/src/ntbs/processor-new.test.ts b/apps/server/src/ntbs/processor-new.test.ts index 8b351c207946..12104cbae7ec 100644 --- a/apps/server/src/ntbs/processor-new.test.ts +++ b/apps/server/src/ntbs/processor-new.test.ts @@ -13,7 +13,7 @@ import { type ReplyPending, type ReplyPosted, type Request, - type T3WorkCoordinates, + type WorkCoordinates, type ThreadCreated, } from "./exchange.ts"; import { makeNTBSProcessor, type NTBSProcessor, type T3Target } from "./processor.ts"; @@ -64,12 +64,13 @@ const target: T3Target = { baseRef: "fork/dev", }; -const coordinates: T3WorkCoordinates = { +const coordinates: WorkCoordinates = { projectId, - baseRefSha: "base-ref-sha", + startBranchName: "fork/dev", + startCommitSha: "start-commit-sha", threadId: ThreadId.make("thread-1"), userMessageId: MessageId.make("message-1"), - branchName: "ntbs/thread-1", + worktreeBranchName: "ntbs/thread-1", }; const secondRequest: Request = { @@ -77,12 +78,12 @@ const secondRequest: Request = { sourceUri: "test://request/2", }; -const secondCoordinates: T3WorkCoordinates = { +const secondCoordinates: WorkCoordinates = { ...coordinates, - baseRefSha: "second-base-ref-sha", + startCommitSha: "second-start-commit-sha", threadId: ThreadId.make("thread-2"), userMessageId: MessageId.make("message-2"), - branchName: "ntbs/thread-2", + worktreeBranchName: "ntbs/thread-2", }; const waitForStoredState = ( @@ -111,9 +112,9 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => + planCoordinates: () => Effect.sync(() => { - t3Calls.push("planT3Work"); + t3Calls.push("planCoordinates"); return coordinates; }), getThreadStatus: () => @@ -152,7 +153,7 @@ describe("NTBSProcessor", () => { expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); expect(acknowledgements).toEqual([expected]); expect(t3Calls).toEqual([ - "planT3Work", + "planCoordinates", "getThreadStatus", "provisionThread", "getTurnStatus", @@ -169,7 +170,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => Effect.succeed(coordinates), + planCoordinates: () => Effect.succeed(coordinates), getThreadStatus: () => Effect.succeed({ thread: "present" }), getTurnStatus: () => Effect.succeed({ turn: "missing" }), startTurn: () => @@ -209,7 +210,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => Effect.succeed(coordinates), + planCoordinates: () => Effect.succeed(coordinates), getThreadStatus: () => Effect.succeed({ thread: "present" }), getTurnStatus: () => Effect.succeed({ turn: "active" }), startTurn: () => @@ -252,7 +253,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => Effect.succeed(coordinates), + planCoordinates: () => Effect.succeed(coordinates), getThreadStatus: () => Effect.succeed({ thread: "missing" }), provisionThread: () => Effect.gen(function* () { @@ -300,7 +301,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => Effect.succeed(coordinates), + planCoordinates: () => Effect.succeed(coordinates), getThreadStatus: () => Effect.succeed({ thread: "present" }), getTurnStatus: () => Effect.succeed({ turn: "missing" }), startTurn: () => @@ -352,10 +353,10 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => + planCoordinates: () => Effect.gen(function* () { planCalls += 1; - t3Calls.push("planT3Work"); + t3Calls.push("planCoordinates"); if (planCalls === 1) { yield* Deferred.succeed(firstPlanStarted, undefined); @@ -396,7 +397,7 @@ describe("NTBSProcessor", () => { .process(request, target) .pipe(Effect.forkChild({ startImmediately: true })); - // The first request now holds the source lock inside planT3Work. + // The first request now holds the source lock inside planCoordinates. yield* Deferred.await(firstPlanStarted); const second = yield* processor @@ -416,7 +417,7 @@ describe("NTBSProcessor", () => { expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); expect(acknowledgements).toEqual([expected]); expect(t3Calls).toEqual([ - "planT3Work", + "planCoordinates", "getThreadStatus", "provisionThread", "getTurnStatus", @@ -437,7 +438,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => + planCoordinates: () => Effect.gen(function* () { planCalls += 1; @@ -514,7 +515,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => + planCoordinates: () => Effect.sync(() => { planCalls += 1; return coordinates; @@ -605,7 +606,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => + planCoordinates: () => Effect.gen(function* () { planCalls += 1; @@ -675,10 +676,10 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => + planCoordinates: () => Effect.gen(function* () { planCalls += 1; - t3Calls.push("planT3Work"); + t3Calls.push("planCoordinates"); if (planCalls === 1) { yield* Deferred.succeed(firstPlanStarted, undefined); @@ -749,7 +750,7 @@ describe("NTBSProcessor", () => { expect(yield* repository.findBySourceUri(request.sourceUri)).toEqual(expected); expect(acknowledgements).toEqual([expected]); expect(t3Calls).toEqual([ - "planT3Work", + "planCoordinates", "getThreadStatus", "provisionThread", "getTurnStatus", @@ -771,7 +772,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => + planCoordinates: () => Effect.gen(function* () { planCalls += 1; @@ -1664,7 +1665,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => Effect.succeed(coordinates), + planCoordinates: () => Effect.succeed(coordinates), getThreadStatus: () => Effect.succeed({ thread: "missing" }), provisionThread: () => Effect.gen(function* () { @@ -1722,7 +1723,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => Effect.succeed(coordinates), + planCoordinates: () => Effect.succeed(coordinates), getThreadStatus: () => Effect.succeed({ thread: "present" }), getTurnStatus: () => Effect.succeed({ turn: "missing" }), startTurn: () => @@ -1779,7 +1780,7 @@ describe("NTBSProcessor", () => { return withTestProcessor( { t3: { - planT3Work: () => Effect.succeed(coordinates), + planCoordinates: () => Effect.succeed(coordinates), getThreadStatus: () => Effect.succeed({ thread: "present" }), getTurnStatus: () => Effect.succeed({ diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 0a4fed936092..875a561abcd9 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -348,7 +348,7 @@ export const makeNTBSProcessor: Effect.Effect { - describe("planT3Work", () => { + describe("planCoordinates", () => { describe("successful planning", () => { it.todo("pins the selected branch to the commit fetched from origin"); diff --git a/apps/server/src/ntbs/t3gateway.ts b/apps/server/src/ntbs/t3gateway.ts index f3a820def23c..948e7657ca6a 100644 --- a/apps/server/src/ntbs/t3gateway.ts +++ b/apps/server/src/ntbs/t3gateway.ts @@ -11,6 +11,9 @@ import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSna import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { DEFAULT_THREAD_TITLE } from "@t3tools/shared/threadTitle"; +import { interrupt } from "effect/Cause"; /* NTBS architecture: @@ -38,7 +41,7 @@ export class T3GatewayError extends Data.TaggedError("T3GatewayError")<{ cause: unknown; }> {} -type _T3GatewayRequirements = +type T3GatewayRequirements = /* Dispatches thread creation and turn-start commands. Provides the T3 event stream used to detect outcomes. @@ -71,12 +74,24 @@ export class T3Rejected extends Data.TaggedError("T3Rejected")<{ cause: unknown; }> {} +/** A branch on `origin` and the commit it pointed at when it was resolved. */ +interface RemoteBranchTip { + readonly branchName: string; + readonly commitSha: string; +} + export interface T3Gateway { - /** Resolves the requested base ref to a commit SHA and mints the thread, message, and branch IDs recorded at claim. */ - readonly planT3Work: ( + /** + * Pins the requested branch to its current commit on `origin` and mints the thread, message, and + * worktree branch identifiers recorded at claim. + * + * Creates nothing: no thread, no worktree, no turn. Every call mints fresh identifiers, so call it + * once per request and persist the result — a second call orphans the work the first one planned. + */ + readonly planCoordinates: ( projectId: ProjectId, baseRef: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly getThreadStatus: ( state: NTBS.RequestClaimed, @@ -102,49 +117,140 @@ export interface T3Gateway { export const T3Gateway = Context.Service("t3code/ntbs/t3Gateway"); -const T3GatewayLive: Effect.Effect = Effect.sync(function () { - const planT3Work = ( - projectId: ProjectId, - baseRef: string, - ): Effect.Effect => - Effect.succeed({ - projectId, - baseRefSha: baseRef + " sha", - threadId: ThreadId.make("some thread id"), - userMessageId: MessageId.make("userMessageId"), - branchName: baseRef + " branchName", - }); - - const getThreadStatus = ( - _state: NTBS.RequestClaimed, - ): Effect.Effect => - Effect.succeed({ - thread: "missing", - }); - - const getTurnStatus = ( - _state: NTBS.ThreadCreated, - ): Effect.Effect => - Effect.succeed({ - turn: "missing", - }); - - const startTurn = (_state: NTBS.ThreadCreated) => Effect.void; - - const threadActivity = Stream.never; - - const provisionThread = ( - _state: NTBS.RequestClaimed, - ): Effect.Effect => Effect.void; - - return { - startTurn, - getTurnStatus, - threadActivity, - planT3Work, - getThreadStatus, - provisionThread, - }; -}); +const T3GatewayLive: Effect.Effect = Effect.gen( + function* () { + const orFail = (reason: string) => + Effect.mapError( + (cause: unknown) => + new T3GatewayError({ + reason, + cause, + }), + ); + + const orReject = (reason: string) => + Effect.mapError( + (cause: unknown) => + new T3Rejected({ + reason, + cause, + }), + ); + + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const getProject = (projectId: ProjectId) => + projectionSnapshotQuery + .getProjectShellById(projectId) + .pipe(Effect.andThen(Effect.fromOption), orFail("Failed fetching projectId " + projectId)); + + const gitWorkflowService = yield* GitWorkflowService; + + /** + * Resolves `branchName` to the commit it currently points at on `origin`. + * + * Fetches first, so the answer reflects the current remote tip even when the local copy is behind. Only `origin` is consulted: local state is never a fallback, because two requests naming the same branch must start from the same commit. + * + * Rejects when the project has no `origin`, or when `branchName` is not a branch on it. + */ + const resolveRemoteBranchTip = ( + cwd: string, + branchName: string, + ): Effect.Effect => + Effect.gen(function* () { + // Check if origin exist. If not, T3 will never be able to accept this work + yield* gitWorkflowService + .remoteExists({ cwd, remoteName: "origin" }) + .pipe(orReject("Remote 'origin' does not exist")); + + // Since it exists, let's fetch the latest remote state + yield* gitWorkflowService + .fetchRemote({ cwd, remoteName: "origin" }) + .pipe(orFail("Could not fetch origin. try again")); + + return yield* gitWorkflowService + .resolveRemoteTrackingCommit({ + cwd, + refName: branchName, + fallbackRemoteName: "origin", + }) + .pipe( + Effect.map((resolved) => ({ + branchName, + commitSha: resolved.commitSha, + })), + ) + .pipe(orFail("Could not resolve remote tracking commit")); + }); + + const crypto = yield* Crypto.Crypto; + const randomUUID = crypto.randomUUIDv4.pipe(orFail("Failed creating a UUID v4")); + + const planCoordinates = ( + projectId: ProjectId, + baseRef: string, + ): Effect.Effect => + Effect.gen(function* () { + /* + 1. Resolve the target project + 2. Resolve the branch - commit pair against which we will create our work tree. + 3. Mind thread, branch, message IDs + */ + + const project = yield* getProject(projectId); + + const remoteBranchTip = yield* resolveRemoteBranchTip(project.workspaceRoot, baseRef); + + const threadUUID = yield* randomUUID; + const threadId = ThreadId.make(threadUUID); + + const userMessageId = MessageId.make(yield* randomUUID); + + // Derived from the thread UUID so a stray branch points back at its thread. + const worktreeBranchName = buildTemporaryWorktreeBranchName(() => threadUUID); + + const coordinates: NTBS.WorkCoordinates = { + projectId, + startBranchName: remoteBranchTip.branchName, + startCommitSha: remoteBranchTip.commitSha, + threadId, + userMessageId, + worktreeBranchName, + }; + return coordinates; + }); + + const getThreadStatus = ( + _state: NTBS.RequestClaimed, + ): Effect.Effect => + Effect.succeed({ + thread: "missing", + }); + + const getTurnStatus = ( + _state: NTBS.ThreadCreated, + ): Effect.Effect => + Effect.succeed({ + turn: "missing", + }); + + const startTurn = (_state: NTBS.ThreadCreated) => Effect.void; + + const threadActivity = Stream.never; + + const provisionThread = ( + _state: NTBS.RequestClaimed, + ): Effect.Effect => Effect.void; + + return { + startTurn, + getTurnStatus, + threadActivity, + planCoordinates, + getThreadStatus, + provisionThread, + }; + }, +); export const t3GatewayLive = Layer.effect(T3Gateway, T3GatewayLive); diff --git a/docs/planning/ntbs-questions.md b/docs/planning/ntbs-questions.md new file mode 100644 index 000000000000..f671219ba7de --- /dev/null +++ b/docs/planning/ntbs-questions.md @@ -0,0 +1,3 @@ +# NTBS open questions + +- Do we need separate t3gateway APIs for `planT3Work`, etc.? From ee699903ed7dc95d6142ccf21fdc816195e5f416 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 26 Aug 2026 19:47:16 +0200 Subject: [PATCH 107/110] feat: handle edge cases in t3gateway planCoordinates --- apps/server/src/ntbs/t3gateway.test.ts | 6 ++-- apps/server/src/ntbs/t3gateway.ts | 50 +++++++++++++++++++++----- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/server/src/ntbs/t3gateway.test.ts b/apps/server/src/ntbs/t3gateway.test.ts index 2a8793e3e349..161268d4af94 100644 --- a/apps/server/src/ntbs/t3gateway.test.ts +++ b/apps/server/src/ntbs/t3gateway.test.ts @@ -13,6 +13,8 @@ describe("T3Gateway", () => { describe("rejected planning", () => { it.todo("rejects a project that does not exist without performing provisioning work"); + it.todo("rejects a project whose repository has no origin remote"); + it.todo( "rejects a selected branch that is absent after a successful fetch without performing provisioning work", ); @@ -21,9 +23,9 @@ describe("T3Gateway", () => { describe("operational failures", () => { it.todo("fails retryably when the project lookup fails"); - it.todo("fails retryably when fetching origin fails"); + it.todo("fails retryably when checking for the origin remote fails"); - it.todo("fails retryably when resolving the fetched branch fails operationally"); + it.todo("fails retryably when fetching origin fails"); it.todo("fails retryably when the exchange IDs cannot be minted"); }); diff --git a/apps/server/src/ntbs/t3gateway.ts b/apps/server/src/ntbs/t3gateway.ts index 948e7657ca6a..e10e3ff07632 100644 --- a/apps/server/src/ntbs/t3gateway.ts +++ b/apps/server/src/ntbs/t3gateway.ts @@ -5,7 +5,7 @@ with T3, similar to how adapter models the interaction with the external platfor import { MessageId, type ProjectId, ThreadId } from "@t3tools/contracts"; import type * as NTBS from "./exchange.ts"; -import { Context, Crypto, Data, Effect, Layer, Stream } from "effect"; +import { Context, Crypto, Data, Effect, Layer, Option, Stream } from "effect"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; @@ -139,10 +139,26 @@ const T3GatewayLive: Effect.Effect = Ef const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + /* + The lookup failing is operational; the project being absent is not. + A deleted or archived project will never come back, so retrying is pointless. + */ const getProject = (projectId: ProjectId) => - projectionSnapshotQuery - .getProjectShellById(projectId) - .pipe(Effect.andThen(Effect.fromOption), orFail("Failed fetching projectId " + projectId)); + projectionSnapshotQuery.getProjectShellById(projectId).pipe( + orFail("Could not load project " + projectId), + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new T3Rejected({ + reason: "Project " + projectId + " does not exist", + cause: null, + }), + ), + }), + ), + ); const gitWorkflowService = yield* GitWorkflowService; @@ -158,16 +174,32 @@ const T3GatewayLive: Effect.Effect = Ef branchName: string, ): Effect.Effect => Effect.gen(function* () { - // Check if origin exist. If not, T3 will never be able to accept this work - yield* gitWorkflowService - .remoteExists({ cwd, remoteName: "origin" }) - .pipe(orReject("Remote 'origin' does not exist")); + // Check if origin exist. If not, T3 will never be able to accept this work. + // The lookup failing is operational; a definitive `false` is not. + yield* gitWorkflowService.remoteExists({ cwd, remoteName: "origin" }).pipe( + orFail("Could not check whether the remote 'origin' exists"), + Effect.filterOrFail( + (exists) => exists, + () => + new T3Rejected({ + reason: "Remote 'origin' does not exist", + cause: null, + }), + ), + ); // Since it exists, let's fetch the latest remote state yield* gitWorkflowService .fetchRemote({ cwd, remoteName: "origin" }) .pipe(orFail("Could not fetch origin. try again")); + /* + This reads the tracking ref locally, with no network involved. The two calls above + already proved the repository is usable and the remote reachable, so what is left + fails only when the branch is absent from origin. A repository broken badly enough + to fail the read some other way is reported as a missing branch; the trade buys us + a permanent rejection for the case that actually happens, a mistyped branch name. + */ return yield* gitWorkflowService .resolveRemoteTrackingCommit({ cwd, @@ -180,7 +212,7 @@ const T3GatewayLive: Effect.Effect = Ef commitSha: resolved.commitSha, })), ) - .pipe(orFail("Could not resolve remote tracking commit")); + .pipe(orReject("Branch '" + branchName + "' does not exist on origin")); }); const crypto = yield* Crypto.Crypto; From ce4385437ecbb199fded5af36e28706ad725e7ba Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Wed, 26 Aug 2026 23:21:42 +0200 Subject: [PATCH 108/110] chore: bump tests --- apps/server/src/ntbs/t3gateway.test.ts | 54 +++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ntbs/t3gateway.test.ts b/apps/server/src/ntbs/t3gateway.test.ts index 161268d4af94..3d209cd9a2bd 100644 --- a/apps/server/src/ntbs/t3gateway.test.ts +++ b/apps/server/src/ntbs/t3gateway.test.ts @@ -1,10 +1,62 @@ -import { describe, it } from "@effect/vitest"; +import { describe, it, assert } from "@effect/vitest"; +import { t3GatewayLive } from "./t3gateway.ts"; +import { Effect, Layer } from "effect"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { Crypto } from "effect/Crypto"; + +/* +T3 Gateway Live is a layer, a constructor of a dependency. + +But it needs its own dependencies and we need to provide/mock them. + +Let's try using Layer.mock to provide those. +*/ +t3GatewayLive; + +const CryptoMock = Layer.mock(Crypto, { + "~effect/platform/Crypto": "~effect/platform/Crypto", + randomUUIDv4: Effect.succeed("randomUUIDv4"), + nextDoubleUnsafe: () => 0, + nextIntUnsafe: () => 0, +}); + +const OrchestrationEngineServiceMock = Layer.mock(OrchestrationEngineService, {}); + +const ProjectionSnapshotQueryMock = Layer.mock(ProjectionSnapshotQuery, {}); + +const ProjectionTurnRepositoryMock = Layer.mock(ProjectionTurnRepository, {}); + +const GitWorkflowServiceMock = Layer.mock(GitWorkflowService, {}); + +const ProjectSetupScriptRunnerMock = Layer.mock(ProjectSetupScriptRunner, {}); + +const t3Dependencies = Layer.mergeAll( + OrchestrationEngineServiceMock, + ProjectionSnapshotQueryMock, + ProjectSetupScriptRunnerMock, + GitWorkflowServiceMock, + ProjectionTurnRepositoryMock, + CryptoMock, +); describe("T3Gateway", () => { describe("planCoordinates", () => { describe("successful planning", () => { it.todo("pins the selected branch to the commit fetched from origin"); + it.layer(t3Dependencies)((it) => + it.effect("pins the selected branch to the commit fetched from origin", () => + Effect.gen(function* () { + // TODO: Continue from here + const t3Gateway = yield* t3GatewayLive; + }), + ), + ); + it.todo( "returns the project, branch and commit with distinct thread and message IDs and a worktree branch derived from the thread ID", ); From 0c31d2396885d594ef02134fa9da6110b7ac032f Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 27 Aug 2026 11:29:46 +0200 Subject: [PATCH 109/110] chore: updates --- apps/server/src/ntbs/processor-new.test.ts | 2 +- apps/server/src/ntbs/processor.ts | 10 ++++++---- apps/server/src/ntbs/processor2.test.ts | 2 +- apps/server/src/ntbs/t3gateway.test.ts | 13 +++++++++---- apps/server/src/ntbs/t3gateway.ts | 9 ++++++--- apps/server/src/ntbs/test-helpers.ts | 2 +- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/server/src/ntbs/processor-new.test.ts b/apps/server/src/ntbs/processor-new.test.ts index 12104cbae7ec..2da37f349734 100644 --- a/apps/server/src/ntbs/processor-new.test.ts +++ b/apps/server/src/ntbs/processor-new.test.ts @@ -61,7 +61,7 @@ const request: Request = { const target: T3Target = { projectId, - baseRef: "fork/dev", + startBranchName: "fork/dev", }; const coordinates: WorkCoordinates = { diff --git a/apps/server/src/ntbs/processor.ts b/apps/server/src/ntbs/processor.ts index 875a561abcd9..ed50310c57f6 100644 --- a/apps/server/src/ntbs/processor.ts +++ b/apps/server/src/ntbs/processor.ts @@ -33,13 +33,15 @@ The cycle is replay safe: it observes before acting, so a crash or a redelivered export type T3Target = { readonly projectId: ProjectId; /** - * The starting point for the thread's worktree: the new branch is created from this ref. + * The starting point for the thread's worktree: the new branch is created from this one. * - * Usually a branch name such as `main`. Before use it is resolved against `origin`, so the worktree starts from the latest remote commit even when the local copy of the branch is behind. A commit SHA is also accepted and is used as-is. + * Must be a branch that exists on `origin`; it is resolved there, so the worktree starts from the + * latest remote commit even when the local copy is behind. Tags, commit SHAs and local-only + * branches are rejected. * * Set by the platform-specific inbound code. */ - readonly baseRef: string; + readonly startBranchName: string; }; export class NTBSProcessorError extends Data.TaggedError("NTBSProcessorError")<{ @@ -348,7 +350,7 @@ export const makeNTBSProcessor: Effect.Effect { yield* processor.process(request, { projectId: ProjectId.make("some-project"), - baseRef: "main", + startBranchName: "main", }); // Durable dedup: no new thread or turn, no second acknowledgement. diff --git a/apps/server/src/ntbs/t3gateway.test.ts b/apps/server/src/ntbs/t3gateway.test.ts index 3d209cd9a2bd..9b80976cd829 100644 --- a/apps/server/src/ntbs/t3gateway.test.ts +++ b/apps/server/src/ntbs/t3gateway.test.ts @@ -1,5 +1,5 @@ import { describe, it, assert } from "@effect/vitest"; -import { t3GatewayLive } from "./t3gateway.ts"; +import { t3GatewayLive, T3Gateway } from "./t3gateway.ts"; import { Effect, Layer } from "effect"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -7,6 +7,7 @@ import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurn import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; import { Crypto } from "effect/Crypto"; +import { ProjectId } from "@t3tools/contracts"; /* T3 Gateway Live is a layer, a constructor of a dependency. @@ -46,13 +47,17 @@ const t3Dependencies = Layer.mergeAll( describe("T3Gateway", () => { describe("planCoordinates", () => { describe("successful planning", () => { - it.todo("pins the selected branch to the commit fetched from origin"); + const t3GatewayTest = t3GatewayLive.pipe(Layer.provide(t3Dependencies)); - it.layer(t3Dependencies)((it) => + it.layer(t3GatewayTest)((it) => it.effect("pins the selected branch to the commit fetched from origin", () => Effect.gen(function* () { // TODO: Continue from here - const t3Gateway = yield* t3GatewayLive; + const t3Gateway = yield* T3Gateway; + + const projectId = ProjectId.make("test-1"); + + t3Gateway.planCoordinates(projectId, ""); }), ), ); diff --git a/apps/server/src/ntbs/t3gateway.ts b/apps/server/src/ntbs/t3gateway.ts index e10e3ff07632..c461a34543f7 100644 --- a/apps/server/src/ntbs/t3gateway.ts +++ b/apps/server/src/ntbs/t3gateway.ts @@ -90,7 +90,7 @@ export interface T3Gateway { */ readonly planCoordinates: ( projectId: ProjectId, - baseRef: string, + startBranchName: string, ) => Effect.Effect; readonly getThreadStatus: ( @@ -220,7 +220,7 @@ const T3GatewayLive: Effect.Effect = Ef const planCoordinates = ( projectId: ProjectId, - baseRef: string, + startBranchName: string, ): Effect.Effect => Effect.gen(function* () { /* @@ -231,7 +231,10 @@ const T3GatewayLive: Effect.Effect = Ef const project = yield* getProject(projectId); - const remoteBranchTip = yield* resolveRemoteBranchTip(project.workspaceRoot, baseRef); + const remoteBranchTip = yield* resolveRemoteBranchTip( + project.workspaceRoot, + startBranchName, + ); const threadUUID = yield* randomUUID; const threadId = ThreadId.make(threadUUID); diff --git a/apps/server/src/ntbs/test-helpers.ts b/apps/server/src/ntbs/test-helpers.ts index 5f546419bb52..89c4ae5c77a6 100644 --- a/apps/server/src/ntbs/test-helpers.ts +++ b/apps/server/src/ntbs/test-helpers.ts @@ -67,7 +67,7 @@ export const createAdapterRequest = ( sourceUri: uniqueId, }, t3Context: { - baseRef: "fork/dev", + startBranchName: "fork/dev", projectId: ProjectId.make("project"), }, }); From a9d6bccbb0fe9afd6f7e59696d933fd3a6048284 Mon Sep 17 00:00:00 2001 From: enricopolanski Date: Thu, 27 Aug 2026 13:32:11 +0200 Subject: [PATCH 110/110] feat: implement first successful test of t3gateway --- apps/server/src/ntbs/t3gateway.test.ts | 105 +++++++++++++++++++++---- apps/server/src/ntbs/t3gateway.ts | 9 ++- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/apps/server/src/ntbs/t3gateway.test.ts b/apps/server/src/ntbs/t3gateway.test.ts index 9b80976cd829..2ed4080e7e57 100644 --- a/apps/server/src/ntbs/t3gateway.test.ts +++ b/apps/server/src/ntbs/t3gateway.test.ts @@ -1,13 +1,13 @@ -import { describe, it, assert } from "@effect/vitest"; +import { describe, it, expect } from "@effect/vitest"; import { t3GatewayLive, T3Gateway } from "./t3gateway.ts"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option } from "effect"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; import { Crypto } from "effect/Crypto"; -import { ProjectId } from "@t3tools/contracts"; +import { OrchestrationProjectShell, ProjectId } from "@t3tools/contracts"; /* T3 Gateway Live is a layer, a constructor of a dependency. @@ -16,7 +16,6 @@ But it needs its own dependencies and we need to provide/mock them. Let's try using Layer.mock to provide those. */ -t3GatewayLive; const CryptoMock = Layer.mock(Crypto, { "~effect/platform/Crypto": "~effect/platform/Crypto", @@ -27,27 +26,84 @@ const CryptoMock = Layer.mock(Crypto, { const OrchestrationEngineServiceMock = Layer.mock(OrchestrationEngineService, {}); -const ProjectionSnapshotQueryMock = Layer.mock(ProjectionSnapshotQuery, {}); +type PSQMInput = { + getProjectShellById: + | { + success: Partial; + } + | { failure: unknown }; +}; + +const createPSQM = (input?: PSQMInput) => { + return Layer.mock(ProjectionSnapshotQuery, { + getProjectShellById: (projectId) => + Effect.option( + input && "failure" in input.getProjectShellById + ? Effect.fail(String(input.getProjectShellById.failure)) + : Effect.succeed({ + id: projectId, + workspaceRoot: "root", + title: "project-title", + createdAt: new Date().toUTCString(), + updatedAt: new Date().toUTCString(), + defaultModelSelection: null, + scripts: [], + ...(input && + "success" in input.getProjectShellById && { ...input.getProjectShellById.success }), + }), + ), + }); +}; const ProjectionTurnRepositoryMock = Layer.mock(ProjectionTurnRepository, {}); -const GitWorkflowServiceMock = Layer.mock(GitWorkflowService, {}); +// TODO: Can't we simplify it by leveraging default values in params? +// we can pass default arguments in JS +const createGitWorkflowServiceMock = (input?: { + remoteExists?: boolean; + resolvedRemoteSha?: string; +}) => + Layer.mock(GitWorkflowService, { + remoteExists: () => Effect.succeed(!input ? true : !!input.remoteExists), + fetchRemote: () => Effect.void, + resolveRemoteTrackingCommit: (_input) => + Effect.succeed({ + commitSha: input?.resolvedRemoteSha ?? "sha123", + remoteRefName: "remoteRefName", + }), + }); const ProjectSetupScriptRunnerMock = Layer.mock(ProjectSetupScriptRunner, {}); -const t3Dependencies = Layer.mergeAll( - OrchestrationEngineServiceMock, - ProjectionSnapshotQueryMock, - ProjectSetupScriptRunnerMock, - GitWorkflowServiceMock, - ProjectionTurnRepositoryMock, - CryptoMock, -); +const t3Dependencies = (input?: { + pqsm?: { + getProjectShellById?: {}; + }; + gwfs?: { + remoteExists?: boolean; + }; +}) => + Layer.mergeAll( + OrchestrationEngineServiceMock, + createPSQM({ + getProjectShellById: { success: {} }, + }), + ProjectSetupScriptRunnerMock, + createGitWorkflowServiceMock(input?.gwfs), + ProjectionTurnRepositoryMock, + CryptoMock, + ); describe("T3Gateway", () => { describe("planCoordinates", () => { describe("successful planning", () => { - const t3GatewayTest = t3GatewayLive.pipe(Layer.provide(t3Dependencies)); + const t3GatewayTest = t3GatewayLive.pipe( + Layer.provide( + t3Dependencies({ + gwfs: { remoteExists: true }, + }), + ), + ); it.layer(t3GatewayTest)((it) => it.effect("pins the selected branch to the commit fetched from origin", () => @@ -57,7 +113,24 @@ describe("T3Gateway", () => { const projectId = ProjectId.make("test-1"); - t3Gateway.planCoordinates(projectId, ""); + /* + Recap. This will, in order: + - fetch the project details for projectId + - if it cannot load the project due to errors, it will fail with a retryable T3GatewayError + - it if can: + - if the project exists: it will return it + - if it does not: it will fail with a T3Rejected error, one that cannot be retried + */ + const coordinates = yield* t3Gateway.planCoordinates(projectId, "main"); + + expect(coordinates).toEqual({ + projectId, + startBranchName: "main", + startCommitSha: "sha123", + threadId: "randomUUIDv4", // why? + userMessageId: "randomUUIDv4", // why? + worktreeBranchName: "t3code/add4", // why is it this specific value? + }); }), ), ); diff --git a/apps/server/src/ntbs/t3gateway.ts b/apps/server/src/ntbs/t3gateway.ts index c461a34543f7..011a458b5bd3 100644 --- a/apps/server/src/ntbs/t3gateway.ts +++ b/apps/server/src/ntbs/t3gateway.ts @@ -171,7 +171,7 @@ const T3GatewayLive: Effect.Effect = Ef */ const resolveRemoteBranchTip = ( cwd: string, - branchName: string, + startBranchName: string, ): Effect.Effect => Effect.gen(function* () { // Check if origin exist. If not, T3 will never be able to accept this work. @@ -193,6 +193,7 @@ const T3GatewayLive: Effect.Effect = Ef .fetchRemote({ cwd, remoteName: "origin" }) .pipe(orFail("Could not fetch origin. try again")); + // TODO: Is this correct? It doesn't look like this reads the tracking ref locally. /* This reads the tracking ref locally, with no network involved. The two calls above already proved the repository is usable and the remote reachable, so what is left @@ -203,16 +204,16 @@ const T3GatewayLive: Effect.Effect = Ef return yield* gitWorkflowService .resolveRemoteTrackingCommit({ cwd, - refName: branchName, + refName: startBranchName, fallbackRemoteName: "origin", }) .pipe( Effect.map((resolved) => ({ - branchName, + branchName: startBranchName, commitSha: resolved.commitSha, })), ) - .pipe(orReject("Branch '" + branchName + "' does not exist on origin")); + .pipe(orReject("Branch '" + startBranchName + "' does not exist on origin")); }); const crypto = yield* Crypto.Crypto;