Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ Notable changes to Swarmail. Versions follow [semantic versioning](https://semve

## Unreleased

- The `t3-v1-steer` wake target and support for T3 Code's older database are removed. Current T3 Code runs only its V2
engine: it keeps its data in `statev2.sqlite` and no longer has the HTTP dispatch route or turn-start command those
bridges sent, so a `t3-v1-steer` bridge failed its thread check and delivered nothing. A `wake-bridge`, `wake-auto-t3`
or credential rotation configuration that still names `t3-v1-steer` now fails at startup with
`target type t3-v1-steer was removed`; change its `target.type` to `t3-v2-queue`. The old bridge's saved state is
bound to the `t3-v1-steer` destination, so the switched bridge refuses it with `saved mailbox binding differs`: stop
the old bridge, reconcile or discard its pending delivery as `docs/wake-bridges.md` describes for bridge state, then
remove that journal before starting the `t3-v2-queue` bridge. The register hook, `who`, agent
locations, automatic wake and `pr-sessions` read only `statev2.sqlite`. While it is missing, or T3 Code is still
creating it, they find no T3 threads, and `pr-sessions` exits with an error.

- A wake bridge stops with an authentication failure when T3 Code rejects the token its authorization file currently
holds (HTTP 401), instead of retrying forever; a standalone bridge whose token was revoked or expired now exits. A
401 for a token that rotation already replaced is still retried, since rotation writes the new header before revoking
Expand Down
4 changes: 2 additions & 2 deletions docs/pr-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ history and `swarmail.identities`: the swarmail agents whose registration tags n

## Limits

The links are associations, not proof of who implemented the change or released a checkout. Remote environments, T3 V1
stores, hard-deleted conversations and unlinked sessions are not covered. Only GitHub pull requests are supported: if any
The links are associations, not proof of who implemented the change or released a checkout. Remote environments,
hard-deleted conversations and unlinked sessions are not covered. Only GitHub pull requests are supported: if any
conversation in the project links a pull request on another host, such as GitLab, the command fails and exits 1 for
every lookup in that project, not only for that pull request. Agent registrations can be overwritten, and historical
coverage is incomplete.
8 changes: 4 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,10 +369,10 @@ returned page read. It works outside a Git checkout and ignores
The CLI uses one recognized host session variable, or one registration tied to
the current host process's PID and start time. Conflicting variables or several
possible sessions fail instead of choosing one. T3 thread identity also matches
registrations made by an earlier provider in that thread. V2 database lookup
requires one matching provider and native ID; V1 uses recorded registration
tags. If discovery fails,
use the explicit mailbox interface below. Keep registration tags current.
registrations made by an earlier provider in that thread. Looking the thread up
in T3 Code's database needs exactly one T3 thread whose provider and native
session ID match the current session; no match, or more than one, makes
discovery fail. If discovery fails, use the explicit mailbox interface below. Keep registration tags current.

`--limit N` sets the page size, capped at 1000, not a total limit. The drain
stops on a page smaller than that effective limit. `--json` prints one JSON
Expand Down
3 changes: 1 addition & 2 deletions docs/wake-bridges.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ information to an existing session; it does not assign tasks or manage agents.

| Target type | Transport | Delivery |
| ------------------- | --------------------------- | --------------------------------------------------------------------------- |
| `t3-v1-steer` | Loopback HTTP | Starts a turn that steers the active turn using its current modes |
| `t3-v2-queue` | Loopback HTTP and WebSocket | Agent message with steering intent; can reconcile older queued mail notices |
| `opencode-v2-queue` | Loopback HTTP | Prompt with steering delivery and a matching admission receipt |
| `codex-queue` | Unix WebSocket | Native app-server steering with a durable command ID |
Expand Down Expand Up @@ -248,7 +247,7 @@ For a Cursor session that holds a settled automatic T3 registration, `swarmail h
The configuration is the [bridge configuration](#configure-and-run) with these differences:

- Leave out `target.id`; each thread's bridge supplies its own.
- `target.type` is `t3-v1-steer` or `t3-v2-queue`. `target.delivery` is optional and may only be `auto`, with `t3-v2-queue`.
- `target.type` is `t3-v2-queue`. `target.delivery` is optional and may only be `auto`.
- `target.url` is optional. The supervisor reads the port T3 publishes in `<baseDir>/userdata/server-runtime.json` and
uses `target.url` only when that file is missing.
- `rotation` is required: `verifyBackend` must be `true` and `baseDir` is T3's base directory. It enables the
Expand Down
4 changes: 2 additions & 2 deletions scripts/configure-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ export function withClaudeHooks(
/**
* The OpenCode plugin: forwards edit-tool calls to `swarmail register` without waiting on it.
* One default export serves both majors: OpenCode 2 calls `setup(ctx)`, OpenCode 1 (1.18.29+)
* calls `server(input)`. T3 Code V1 drives OpenCode 1, which shares this plugin directory,
* and OpenCode 2 still discovers the singular `plugin/` directory.
* calls `server(input)`. Both share this plugin directory: OpenCode 2 still discovers the
* singular `plugin/` directory.
*/
export function openCodePlugin(bin: string): string {
return `// Installed by the Swarmail hooks installer (scripts/configure-hooks.ts); edits here are overwritten.
Expand Down
6 changes: 3 additions & 3 deletions src/pr-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { existsSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { primaryCheckout } from "./checkout.ts";
import { databasePath, t3Home } from "./paths.ts";
import { isT3V2, t3StatePath } from "./t3-state.ts";
import { t3StateReady, t3StatePath } from "./t3-state.ts";
import { identity } from "./tag.ts";

// ACP registry/custom drivers do not identify a native program; only exact manual tags can match them.
Expand Down Expand Up @@ -586,7 +586,7 @@ export async function prSessions(args: string[]): Promise<number> {
requireArgument(existsSync(file), "local T3 store is missing");
const db = new Database(file, { readonly: true });
try {
requireValue(isT3V2(db), "T3 V1 is unsupported");
requireValue(t3StateReady(db), "local T3 store has no thread tables yet");
db.run("BEGIN");
// Verify the required projection even when no association matches.
db.query(`SELECT provider_thread_id,thread_id,owner_node_id,provider_session_id,updated_at,payload_json
Expand Down Expand Up @@ -617,7 +617,7 @@ export async function prSessions(args: string[]): Promise<number> {
database: file,
rosterDatabase: room.database,
limits:
"Associations are not implementer ownership or checkout release evidence. Remote, V1, non-GitHub, hard-deleted and unlinked sessions are unsupported. Roster identities can be overwritten; historical coverage is incomplete.",
"Associations are not implementer ownership or checkout release evidence. Remote, non-GitHub, hard-deleted and unlinked sessions are unsupported. Roster identities can be overwritten; historical coverage is incomplete.",
},
matches,
},
Expand Down
61 changes: 23 additions & 38 deletions src/t3-state.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
// T3 Code's state database. Orchestrator V2 copies V1's `state.sqlite` to `statev2.sqlite` on its
// first start, then writes only the copy and keeps threads in `orchestration_v2_*` tables; the
// copied V1 tables stay behind, frozen. V1 keeps writing `state.sqlite`.
// T3 Code's state database, `statev2.sqlite`. T3 creates it on its first start by copying its
// older database, then keeps threads in `orchestration_v2_*` tables.
import { Database } from "bun:sqlite";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { t3Home } from "./paths.ts";

/** V2's database once a V2 server has created it, else V1's. */
/** T3's state database under `baseDir`; it may not exist yet. */
export function t3StatePath(baseDir: string): string {
const v2 = join(baseDir, "userdata", "statev2.sqlite");
return existsSync(v2) ? v2 : join(baseDir, "userdata", "state.sqlite");
return join(baseDir, "userdata", "statev2.sqlite");
}

/** Whether `db` holds V2's thread tables. */
export function isT3V2(db: Database): boolean {
/** Whether `db` has its thread tables yet; while T3 is still creating it, it holds no threads. */
export function t3StateReady(db: Database): boolean {
return (
db
.query(
Expand All @@ -26,7 +24,7 @@ export function isT3V2(db: Database): boolean {
/**
* V2's app threads with their current root provider thread. `session` is the provider's own
* session id, `status` is `running` while that provider thread is loaded (V2 `idle` or `active`),
* as V1's runtime rows reported it, and `last_seen_at` is its last update.
* and `last_seen_at` is its last update.
*/
export const T3_V2_THREADS = `
select t.thread_id, t.title, t.project_id, t.archived_at, t.deleted_at,
Expand All @@ -43,8 +41,8 @@ export const T3_V2_THREADS = `

/**
* The T3 Code thread that runs provider session `sessionId`, or null outside T3. T3 passes no
* thread id to the provider process, but V1's resume cursor for the thread and V2's native
* thread reference hold the session id.
* thread id to the provider process, but the provider thread's native reference holds the
* session id. With `host`, the provider must match too.
*/
export function t3ThreadId(
sessionId: string,
Expand All @@ -57,11 +55,10 @@ export function t3ThreadId(
try {
const db = new Database(dbPath, { readonly: true });
try {
if (!t3StateReady(db)) {
return null;
}
if (host !== undefined) {
// V1 resume cursors do not establish the native provider. Use the registration tag there.
if (!isT3V2(db)) {
return null;
}
const driver = host === "claude" ? "claudeAgent" : host === "agy" ? "antigravity" : host;
const rows = db
.query<{ thread_id: string }, [string, string]>(
Expand All @@ -73,17 +70,11 @@ export function t3ThreadId(
}
return rows[0]?.thread_id ?? null;
}
const row = isT3V2(db)
? db
.query<{ thread_id: string }, [string]>(
"select thread_id from orchestration_v2_projection_provider_threads where thread_id is not null and json_extract(payload_json, '$.nativeThreadRef.nativeId') = ? order by updated_at desc limit 1",
)
.get(sessionId)
: db
.query<{ thread_id: string }, [string]>(
"select thread_id from provider_session_runtime where instr(resume_cursor_json, ?) > 0 order by last_seen_at desc limit 1",
)
.get(JSON.stringify(sessionId));
const row = db
.query<{ thread_id: string }, [string]>(
"select thread_id from orchestration_v2_projection_provider_threads where thread_id is not null and json_extract(payload_json, '$.nativeThreadRef.nativeId') = ? order by updated_at desc limit 1",
)
.get(sessionId);
return row?.thread_id ?? null;
} finally {
db.close();
Expand All @@ -104,7 +95,7 @@ export interface T3Thread {
last_seen_at: string | null;
}

/** T3's threads by id; none while the database is missing or mid-copy on V2's first start. */
/** T3's threads by id; none while the database is missing or T3 is still creating it. */
export function t3Threads(dbPath: string): Map<string, T3Thread> {
if (!existsSync(dbPath)) {
return new Map();
Expand All @@ -116,19 +107,13 @@ export function t3Threads(dbPath: string): Map<string, T3Thread> {
return new Map();
}
try {
if (!t3StateReady(db)) {
return new Map();
}
const rows = db
.query<T3Thread, []>(
isT3V2(db)
? `
select thread_id, title, cwd, status, last_seen_at from (${T3_V2_THREADS})
where deleted_at is null`
: `
select t.thread_id, t.title, coalesce(t.worktree_path, p.workspace_root) as cwd,
r.status, r.last_seen_at
from projection_threads t
left join projection_projects p on p.project_id = t.project_id
left join provider_session_runtime r on r.thread_id = t.thread_id
where t.deleted_at is null`,
`select thread_id, title, cwd, status, last_seen_at from (${T3_V2_THREADS})
where deleted_at is null`,
)
.all();
return new Map(rows.map((row) => [row.thread_id, row]));
Expand Down
75 changes: 29 additions & 46 deletions src/wake-auto-t3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
import { Database } from "bun:sqlite";
import { mkdirSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { isT3V2, T3_V2_THREADS, t3StatePath } from "./t3-state.ts";
import { t3StateReady, T3_V2_THREADS, t3StatePath } from "./t3-state.ts";
import {
checkT3Target,
BridgeError,
TargetAuthenticationError,
localUrl,
T3_TYPES,
rejectRemovedTargetType,
} from "./wake-target.ts";
import type { WakeTarget } from "./wake-target.ts";
import { primaryCheckout } from "./checkout.ts";
Expand Down Expand Up @@ -38,8 +38,7 @@ type AutoState = RegisterState & { wakeThread?: string };
* Providers whose T3 V2 threads this service wakes. Claude is left out because Claude in T3
* already waits on its own session id (the wake mod, or the stop hook without it). A bridge would
* wait on the same id, and the server keeps one waiter per id, so each would replace the other
* with a 409. Under T3's Cursor SDK, Cursor's stop hook stops waiting when the turn ends. V1 stays
* Codex-only.
* with a 409. Under T3's Cursor SDK, Cursor's stop hook stops waiting when the turn ends.
*/
const WAKE_PROVIDERS = "('codex','cursor')";

Expand Down Expand Up @@ -147,37 +146,28 @@ export function enrollT3Threads(
): void {
const states = openRegistry<AutoState>(registry);
const rows = readT3((db) =>
db
.query<
{
thread_id: string;
session: unknown;
cwd: string;
last_seen_at: string;
provider_name: string;
},
[string]
>(
isT3V2(db)
? `
t3StateReady(db)
? db
.query<
{
thread_id: string;
session: unknown;
cwd: string;
last_seen_at: string;
provider_name: string;
},
[string]
>(
`
SELECT thread_id, session, last_seen_at, cwd, provider_name FROM (${T3_V2_THREADS}) v
WHERE deleted_at IS NULL AND archived_at IS NULL AND NOT coalesce(subagent, 0)
AND ${UNSETTLED_V2}
AND cwd IS NOT NULL AND provider_name IN ${WAKE_PROVIDERS} AND status='running'
AND julianday(last_seen_at) >= julianday(?)
`
: `
SELECT t.thread_id, CASE WHEN json_valid(r.resume_cursor_json)
THEN json_extract(r.resume_cursor_json,'$.threadId') END AS session, r.last_seen_at,
coalesce(t.worktree_path,p.workspace_root) AS cwd, 'codex' AS provider_name
FROM projection_threads t JOIN provider_session_runtime r ON r.thread_id=t.thread_id
JOIN projection_projects p ON p.project_id=t.project_id
WHERE t.deleted_at IS NULL AND t.archived_at IS NULL
AND t.settled_at IS NULL AND t.settled_override IS NOT 'settled'
AND r.provider_name='codex' AND r.status='running' AND julianday(r.last_seen_at) >= julianday(?)
`,
)
.all(after),
)
.all(after)
: [],
);
for (const row of rows) {
const session = row.session;
Expand Down Expand Up @@ -229,25 +219,18 @@ export function automaticT3Threads(
): Set<string> {
const states = openRegistry<AutoState>(registry);
const rows = readT3((db) =>
db
.query<{ thread_id: string; session: unknown; provider_name: string }, []>(
isT3V2(db)
? `
t3StateReady(db)
? db
.query<{ thread_id: string; session: unknown; provider_name: string }, []>(
`
SELECT thread_id, session, provider_name FROM (${T3_V2_THREADS}) v
WHERE deleted_at IS NULL AND archived_at IS NULL AND NOT coalesce(subagent, 0)
AND ${UNSETTLED_V2}
AND provider_name IN ${WAKE_PROVIDERS}
`
: `
SELECT t.thread_id, CASE WHEN json_valid(r.resume_cursor_json)
THEN json_extract(r.resume_cursor_json,'$.threadId') END AS session,
'codex' AS provider_name
FROM projection_threads t JOIN provider_session_runtime r ON r.thread_id=t.thread_id
WHERE t.deleted_at IS NULL AND t.archived_at IS NULL
AND t.settled_at IS NULL AND t.settled_override IS NOT 'settled' AND r.provider_name='codex'
`,
)
.all(),
)
.all()
: [],
);
const threads = new Set<string>();
// Only the registration named by each thread's provider session can match it.
Expand Down Expand Up @@ -284,10 +267,10 @@ export function automaticT3Threads(

export function automaticT3Configuration(path: string) {
const config = JSON.parse(readFileSync(path, "utf8"));
rejectRemovedTargetType(config.target?.type);
if (
!T3_TYPES.includes(config.target?.type) ||
(config.target.delivery !== undefined &&
(config.target.type !== "t3-v2-queue" || config.target.delivery !== "auto")) ||
config.target?.type !== "t3-v2-queue" ||
(config.target.delivery !== undefined && config.target.delivery !== "auto") ||
config.rotation?.verifyBackend !== true ||
typeof config.rotation?.baseDir !== "string" ||
typeof config.enrollAfter !== "string" ||
Expand Down
10 changes: 8 additions & 2 deletions src/wake-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
// SQLite holds the pending command and an owner lock released when its connection closes.
import { ensureWakeEligible } from "./wake-lifecycle.ts";
import { readFileSync } from "node:fs";
import { BridgeError, localUrl, openCodeAdapter, t3V1Adapter, t3V2Adapter } from "./wake-target.ts";
import {
BridgeError,
localUrl,
openCodeAdapter,
rejectRemovedTargetType,
t3V2Adapter,
} from "./wake-target.ts";
import type { TargetAdapter, WakeTarget } from "./wake-target.ts";
import { grokAdapter } from "./wake-grok.ts";
import type { GrokTarget } from "./wake-grok.ts";
Expand All @@ -20,7 +26,6 @@ type Target = WakeTarget | GrokTarget | CodexTarget;
// The HTTP types share WakeTarget, whose `type` is itself a union.
type TargetOf<K> = K extends WakeTarget["type"] ? WakeTarget : Extract<Target, { type: K }>;
const ADAPTERS: { [K in Target["type"]]: TargetAdapter<TargetOf<K>> } = {
"t3-v1-steer": t3V1Adapter,
"t3-v2-queue": t3V2Adapter,
"opencode-v2-queue": openCodeAdapter,
"grok-queue": grokAdapter,
Expand All @@ -45,6 +50,7 @@ function readConfig(path: string): BridgeConfig {
throw new BridgeError("config requires a target session/thread id");
}
const swarmailUrl = localUrl(input.swarmailUrl ?? "http://127.0.0.1:18765");
rejectRemovedTargetType(target.type);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const adapter = adapterFor(target.type);
if (!adapter) {
throw new BridgeError("config requires target type and absolute authorizationFile");
Expand Down
6 changes: 3 additions & 3 deletions src/wake-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
writeFileSync,
} from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { BridgeError, localUrl, T3_TYPES } from "./wake-target.ts";
import { BridgeError, localUrl, rejectRemovedTargetType } from "./wake-target.ts";
import {
backendTimeout,
checkBackendOperation,
Expand Down Expand Up @@ -87,9 +87,9 @@ function readConfig(path: string) {
const config = JSON.parse(readFileSync(path, "utf8"));
const target = config.target;
const rotation = config.rotation;
rejectRemovedTargetType(target?.type);
if (
!target ||
!T3_TYPES.includes(target.type) ||
target?.type !== "t3-v2-queue" ||
(target.id !== undefined && (typeof target.id !== "string" || !target.id)) ||
!rotation
) {
Expand Down
Loading
Loading