From d62cbbb74e6fc4831d80c2d038d9d71a69058a2e Mon Sep 17 00:00:00 2001 From: "i-am-thor[bot]" <276291138+i-am-thor[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 14:13:23 +0000 Subject: [PATCH 1/4] feat: simplify opencode prompt send Co-authored-by: Son Dao --- AGENTS.md | 1 + ...026052702_simplify-opencode-prompt-send.md | 246 ++++++++++++ packages/common/src/service-env.ts | 1 - packages/gateway/src/app.test.ts | 16 +- packages/gateway/src/app.ts | 6 +- packages/gateway/src/queue.test.ts | 2 +- packages/gateway/src/queue.ts | 2 +- packages/gateway/src/service.test.ts | 32 +- packages/gateway/src/service.ts | 27 +- packages/runner/src/event-bus.test.ts | 41 +- packages/runner/src/event-bus.ts | 32 -- packages/runner/src/index.ts | 171 ++------- packages/runner/src/trigger.test.ts | 361 +++++------------- 13 files changed, 409 insertions(+), 529 deletions(-) create mode 100644 docs/plan/2026052702_simplify-opencode-prompt-send.md diff --git a/AGENTS.md b/AGENTS.md index edcbb95d..e677ec6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ thor/ - **Runtime**: Node.js 22+ - **Formatting**: Default TypeScript/ESLint conventions. No custom config until needed. - **OpenCode version alignment**: When bumping `@opencode-ai/sdk`, also bump the OpenCode server/package version in the Dockerfile in the same change so the client and server stay aligned. +- **OpenCode model limit alignment**: When adding or changing models in `docker/opencode/config/`, update `MODEL_CONTEXT_LIMITS` in `packages/runner/src/index.ts` in the same change so context progress remains available. - **OpenCode event schema/viewer drift**: Before changing OpenCode event persistence, projection, parser schemas, unknown-event fallback rendering, or `unrecognized_opencode_event` handling, read `docs/plan/2026051601_opencode-event-view-schema.md`. - **No frameworks unless justified** — Express for HTTP, raw TypeScript for everything else. Every added dependency should have a reason in the plan. diff --git a/docs/plan/2026052702_simplify-opencode-prompt-send.md b/docs/plan/2026052702_simplify-opencode-prompt-send.md new file mode 100644 index 00000000..094e1bd5 --- /dev/null +++ b/docs/plan/2026052702_simplify-opencode-prompt-send.md @@ -0,0 +1,246 @@ +# Simplify OpenCode prompt-send path + +Collapse the runner's `/trigger` send path so it does the minimum required: optionally abort, send, and uniformly stream NDJSON back. Drop the pre-send status check, the `{busy:true}` re-enqueue contract, and the ad-hoc fire-and-forget JSON shape. + +## Goal + +For every accepted `/trigger`: + +- If `interrupt=true`: call `client.session.abort` (safe no-op on idle sessions per opencode source), then `promptAsync`. +- If `interrupt=false`: just `promptAsync`. No `client.session.status` round-trip. + +For the response: + +- One uniform NDJSON content type for all successful triggers. +- Always start the body with a `start` event so non-stream callers still receive an "accepted" receipt on the wire. +- `stream=false` ends the response immediately after `start`; `stream=true` continues until `done`. + +For the gateway: + +- Remove the `{busy:true}` retry branch and stop parsing the runner's success body. + +## Scope + +**In scope** + +- `packages/runner/src/index.ts` `/trigger` handler: delete `session.status()` check, delete the `{busy:true}` response, delete the `interrupt=true` 503 `waitForSessionSettled` path, unify response shape to NDJSON with `start` as the first line. +- `packages/gateway/src/service.ts` `triggerRunnerPrompt`: drop body parse on 2xx, drop the `json.busy` branch, simplify the `TriggerResult` type. +- `packages/gateway/src/app.ts` dispatch handler: drop the `result.busy` branch and `"busy"` outcome from `logTrigger`. +- Tests in `packages/runner` and `packages/gateway` that assert today's busy-retry behavior or the `{accepted,sessionId,resumed}` JSON shape. + +**Out of scope** + +- Changes to the upstream `@opencode-ai/sdk` or to opencode server behavior. The `Running`-collision race in opencode's `ensureRunning` (work discarded; pickup via the loop's polling, racy at end-of-loop) is acknowledged but not fixed here. +- The gateway-side debounce/batching in `packages/gateway/src/queue.ts`. That is an independent mechanism and stays. +- New `delayMs` field on `TriggerRequestSchema`. Existing gateway debounce already covers the "delay as needed" semantics for current callers; a per-trigger delay can be added later if a caller needs it. +- Slack progress transport, memory injection, child-session forwarding, trigger lifecycle (`startTrigger`/`endTrigger`), and the smoke test contract. These are preserved. +- Exposing `triggerId` on the wire. Stays internal. + +Additionally in scope (model-limit simplification, see Phase 5 below): + +- Replace the dynamic per-provider model context-limit fetch with a hardcoded constant table. +- Drop the entire cache (`MODEL_CONTEXT_LIMIT_CACHE_TTL_MS`, `cachedModelContextLimits`, `cachedModelContextLimitsPending`), the warm-up call, the OpenCode `provider.list` round-trip, and the test-only cache-reset hook. +- Keep `ProgressContextSchema` and the `emitContextProgressFromMessage` flow; only the source of `limit` changes. + +## Current design notes + +- Trigger handler today: `packages/runner/src/index.ts:743-1326`. +- Status check + busy branch: `:857-897`. Returns `{busy:true}` for `interrupt=false`, or aborts + `waitForSessionSettled` with 503-on-timeout for `interrupt=true`. +- Success response today: `:1310` writes `{accepted:true, sessionId, resumed}` JSON when `stream=false`; otherwise NDJSON ending with `done`. +- `emit()` (`:989-1026`) gates `res.write()` on the `stream` flag at `:1013`. The `start` event is already constructed at `:1028-1033` but never reaches the wire in fire-and-forget mode. +- Gateway consumer: `packages/gateway/src/service.ts:543-573`. Reads `json.busy`; that is the only field it consumes from the success body. Confirmed by grep — no callsite reads `accepted`, `sessionId`, or `resumed` from the gateway side. +- Dispatch handler busy-log: `packages/gateway/src/app.ts:1154-1161`. The `result.busy` branch returns without calling `ack()`; that is what causes the queue to retain the file for the next scan (`packages/gateway/src/queue.ts:70-74`). +- Verified behavior of opencode (from reading `/Users/son.dao/repos/daohoangson/opencode`): + - `Session.cancel` is a safe no-op when the runner is idle (`packages/opencode/src/session/run-state.ts:80-82`). + - `ensureRunning` does **not** queue `work` on a `Running` collision — the new `work` is discarded and the caller attaches to the in-flight deferred (`packages/opencode/src/effect/runner.ts:120-122`). Pickup of the new user message depends on the loop's `MessageV2.filterCompactedEffect` re-read each iteration; the exit guard at `prompt.ts:1268-1276` uses the iteration's stale snapshot, so a message persisted during the final iteration can be missed. + - `Shell` and `ShellThenRun` are safe collisions in practice: a fresh `runLoop` runs later and reads the DB, picking up new user messages. + - The status check on the runner today does not actually prevent the race because the opencode runner state is not held under any lock the runner shares with the gateway — `status()` then `promptAsync()` is TOCTOU. + +## Response contract after this change + +| `stream` | Status | Content-Type | Body | +| -------- | ------ | ---------------------- | --------------------------------------------------------------------- | +| false | 200 | `application/x-ndjson` | one line: `start` event, then EOF | +| true | 200 | `application/x-ndjson` | NDJSON: `start` … (`tool`, `memory`, `delegate`, `context` …) `done` | +| any | 400 | json | rejection reason (Zod parse, directory not allowed) | +| any | 500 | json | error (promptAsync error from opencode, uncaught exception) | + +Every 2xx body begins with a `start` event. The `stream` flag controls how long the connection stays open afterward, not the shape. + +## Phases + +### Phase 1 — Runner: unify the response shape + +**Changes** + +- Move `res.setHeader("Content-Type", "application/x-ndjson")` and `res.flushHeaders?.()` out of the `if (stream)` guard so they run for every successful trigger. +- In `emit()`, drop the `stream &&` half of the write guard. The remaining `!res.writableEnded` check is sufficient: post-`end()` writes silently no-op in the background task. +- After the existing `start` emission at `:1028-1033`, branch on `stream`: + - `stream === true`: keep the existing `await backgroundTask; if (!res.writableEnded) res.end();` shape. + - `stream === false`: replace `res.json({accepted:true,sessionId,resumed})` with `res.end()` and fire-and-forget the background task as today. +- Decide where to emit the `bootstrapMemoryPaths` `memory` events: keep them where they are (`:1035-1037`, before background task) so non-stream callers see them too, or move them into the background task so non-stream bodies are strictly one line. Pick "strictly one line" for cleanliness; document in the decision log. +- Update the schema comment at `:553-559` to describe the new shape. + +**Exit criteria** + +- Hitting `/trigger` with `stream=false` returns HTTP 200, `Content-Type: application/x-ndjson`, one NDJSON line (`{"type":"start",...}`), then EOF. +- Hitting `/trigger` with `stream=true` is unchanged: NDJSON terminated by `done`. +- Runner unit test asserts both shapes. + +### Phase 2 — Runner: remove the status check and busy branch + +**Changes** + +- In `packages/runner/src/index.ts:857-897`, delete the `client.session.status({})` call and the entire `if (sessionStatus?.type === "busy")` block. +- Replace with: `if (resumed && parsed.data.interrupt === true) { … abort … }`. +- Abort step: `endTrigger` the prior in-flight trigger for this session if any, then `await client.session.abort({path:{id:sessionId}}).catch(/* defensive — abort is documented-safe */);`. +- Delete `waitForSessionSettled`, `ABORT_TIMEOUT`, and the 503 path. `cancel` in opencode's `SynchronizedRef.modify` commits the `Idle` transition before the HTTP `abort` response returns, so an immediately-following `promptAsync` lands in the `Idle` branch. +- Drop `{busy:true}` (`:867`) from the response set. +- If `waitForSessionSettled` has no other callers, delete it. Same for `ABORT_TIMEOUT` if unreferenced after this phase. + +**Exit criteria** + +- `/trigger` with `interrupt=true` against an idle session sends the prompt without 503 or extra waits. +- `/trigger` with `interrupt=true` against a busy session aborts and sends the prompt; no `waitForSessionSettled`. +- `/trigger` with `interrupt=false` never calls `client.session.status`; it goes straight to `promptAsync`. +- Runner trigger tests cover all three paths. + +### Phase 3 — Gateway: drop the `{busy:true}` contract + +**Changes** + +- `packages/gateway/src/service.ts:543-573` — `triggerRunnerPrompt` becomes: + - On `!response.ok` 4xx: call `onRejected`, return `{ rejected: true, reason }`. + - On `!response.ok` 5xx: throw (unchanged; queue handler logs and retains the file). + - On 2xx: call `onAccepted`, return `{ rejected: false }`. No `await response.json()`, no `Record` cast. +- Collapse the `TriggerResult` union to `{ rejected: true; reason: string } | { rejected: false }`. Drop the `busy` discriminator. Update the other `return { busy: false, ... }` site at `:761`. +- `packages/gateway/src/app.ts:1154-1161` — replace the three-way branch with: + ```ts + const result = await executeBatchDispatchPlan(plan); + if (result.rejected) logTrigger(plan.logPrefix, "dropped", result.reason); + else logTrigger(plan.logPrefix, "fired"); + ``` +- `logTrigger` outcome union (`app.ts:1067`) drops `"busy"`. +- Tests: + - `packages/gateway/src/service.test.ts` lines 335, 358, 392 — three cases mocking `{busy:true}` are obsolete. Either delete them or rewrite to assert "2xx always acks." Keep the 5xx-retry assertion. + - `packages/gateway/src/app.test.ts` lines 511, 3474 — same; assertions that a busy response leaves the queue untouched go. Add a test that a 2xx empty body and a 2xx NDJSON body both ack. + +**Exit criteria** + +- Gateway never reads the runner's success body. +- Gateway test suite passes with no `{busy:true}` fixtures. +- Queue retry path still works for 5xx and network errors (covered by existing throw-path tests). + +### Phase 4 — Replace dynamic model-limit fetching with a hardcoded constant + +**Background** + +- `packages/runner/src/index.ts:97-111, 855, 901, 1124, 1350-1405` implement a 5-minute TTL cache of per-provider per-model context limits, populated by calling `client.provider.list({})` and reading `model.limit.context` for each provider's models. +- The cache is consumed in exactly one place: `emitContextProgressFromMessage` at `:1414-1441`, which looks up `limits.get("${providerID}/${modelID}")` to compute `usagePercent` for the `context` progress event. +- The warm-up call (`warmModelContextLimits`) is the only reason the trigger handler awaits anything before `promptAsync`. After this phase, that `await` goes away too. +- Only two production models are configured in this repo's opencode agent set: + - `openai/gpt-5.4` — `docker/opencode/config/agents/build.md` (primary) + - `openai/gpt-5.5` — `docker/opencode/config/agents/coder.md`, `thinker.md` (subagents) + - `openai/gpt-5.4-mini` is listed as `small_model` in `docker/opencode/config/opencode.json` but is not used by the named agents. +- The user-stated limit for both `gpt-5.4` and `gpt-5.5` is **1,050,000 tokens**. +- The `provider.list` endpoint has no other caller in the repo (`rg "provider\.list|providers\.list"` returns one hit). + +**Changes** + +- In `packages/runner/src/index.ts`: + - Replace lines 97-111 (cache state and `resetModelContextLimitCacheForTests`) with a single module-level constant: + ```ts + const MODEL_CONTEXT_LIMITS = new Map([ + ["openai/gpt-5.4", 1_050_000], + ["openai/gpt-5.5", 1_050_000], + ]); + ``` + - Delete the `export function resetModelContextLimitCacheForTests` symbol entirely. + - Delete `resolveModelContextLimits` (`:1354-1371`), `currentModelContextLimits` (`:1373-1378`), and `warmModelContextLimits` (`:1380-1405`). + - Delete the warm-up call at `:855` (`const warmModelLimits = warmModelContextLimits(...)`) and the `await warmModelLimits` at `:901`. After Phase 2 these are the last awaited side effects between session resolve and `promptAsync`, so removing them tightens the path further. + - At `:1124`, change `emitContextProgressFromMessage(event, currentModelContextLimits(), emit)` to `emitContextProgressFromMessage(event, MODEL_CONTEXT_LIMITS, emit)`. + - Keep `contextLimitKey()` (`:1350-1352`) and `emitContextProgressFromMessage()` (`:1414-1441`) unchanged. The "no limit known → skip context event" branch at `:1431` already gives us the right behavior for unknown models (mini, future additions): no `context` event until the constant table is updated. + - Drop the `model_context_limits_load_failed` and `model_context_limits_warm_failed` log names — both go with `resolveModelContextLimits` and `warmModelContextLimits`. + +- In `packages/runner/src/trigger.test.ts`: + - Remove the `resetModelContextLimitCacheForTests` import and call at `:8, :352`. + - Remove `providerList`, `onProviderList`, and `providerLists`-counting fixtures from the harness for context-progress tests. The `client.provider.list` mock is no longer exercised by the trigger handler. + - Rewrite or replace the affected test cases (`:1222, :1261, :1299, :1530, :1554`) so they target `openai/gpt-5.4` / `openai/gpt-5.5` with the constant 1,050,000 limit. Expected math: 126,000 input/output/reasoning tokens against 1,050,000 → `usagePercent: 12`. + - The "skips context progress when no positive configured model limit is known" case (`:1530`) becomes "skips context progress for models not in the constant table" — change the `modelID` in the fixture to one absent from the table (`gpt-5.4-mini` is a natural fit). + - The "keys context limits by provider and model to avoid same-model collisions" case (`:1261`) loses its premise (no `anthropic/gpt-5.4` entry in the table) — delete it. The keying logic is still exercised by the lookup at `:1430` and covered by inspection. + - Any harness option for stubbing `client.provider.list` can be deleted if unused after these test edits. + +**Decision: how to extend later** + +- Adding a new model (or correcting a limit) is a one-line edit to `MODEL_CONTEXT_LIMITS`. The constant lives next to the agent configs conceptually but stays in code for now because `emitContextProgressFromMessage` runs in-process. A future move to a JSON/YAML config alongside `docker/opencode/config/` is possible but out of scope. +- The agent config files (`docker/opencode/config/agents/build.md`, `coder.md`, `thinker.md`, plus `opencode.json`) are touched only by future model-list changes. When a new model is added to those files, the same PR must add the model to `MODEL_CONTEXT_LIMITS` — call out in `AGENTS.md §6` style discipline (environment-variable discipline applied analogously to model registration). + +**Exit criteria** + +- No call to `client.provider.list` anywhere in `packages/runner`. +- No `cachedModelContextLimits*` symbols, no `warmModelContextLimits`, no `resolveModelContextLimits`, no `currentModelContextLimits`, no `resetModelContextLimitCacheForTests`. +- `emitContextProgressFromMessage` produces a `context` event with `limit: 1_050_000` for `openai/gpt-5.4` and `openai/gpt-5.5` token updates. +- `emitContextProgressFromMessage` skips the event for any provider/model pair not in the table (e.g. `gpt-5.4-mini`). +- Trigger handler has no `await` between session-resolve and `promptAsync` (other than the new `abort` call when `interrupt=true`). +- Runner trigger tests covering context progress pass without any `provider.list` fixture. + +### Phase 5 — Documentation, tests, and integration verification + +**Changes** + +- Update any docs/comments referencing `{busy:true}` or `{accepted:true,...}` from the runner. The `/trigger` schema comment at `runner/src/index.ts:548-559` is the main one. +- Search for `progress_relay`, `ndjson_parse_skip`, `runner_response_drain_error`, `ABORT_TIMEOUT`, `waitForSessionSettled`, and `session_busy_*` log names. Remove or rename anything that no longer applies. +- Push the branch to GitHub. The opencode E2E workflow (`scripts/test-opencode-e2e.sh`) is the integration gate per AGENTS.md §3 — it exercises `stream:true` and `parse_done`, which is the smoke test for both the NDJSON shape and the resume path. Dispatch manually if not auto-triggered. + +**Exit criteria** + +- `pnpm --filter @thor/runner typecheck` and runner tests green. +- `pnpm --filter @thor/gateway typecheck` and gateway tests green. +- `pnpm --filter @thor/common typecheck` green (no schema change expected; runs as sanity). +- Opencode E2E workflow green on the push. +- PR open against `main`. + +## Decision log + +| # | Decision | Rationale | Rejected | +| --- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Drop the `client.session.status()` check entirely | The check is TOCTOU: opencode session state can change between status and `promptAsync`. The runner is not the only thing that can transition a session into `Running` (sub-agent `continueIfIdle` and Task tool can too). In practice the check shrinks the lossy window without closing it. | Keep the status check as best-effort. Adds complexity for a guarantee it does not provide. | +| 2 | Call `abort` unconditionally when `interrupt=true`, with no `waitForSessionSettled` | Opencode's `Session.cancel` is documented-safe on idle sessions (`run-state.ts:80-82`). The `Running`-case path uses `SynchronizedRef.modify` to commit `Idle` before the cleanup effect runs, so the next `promptAsync` lands in the `Idle` branch of `ensureRunning`. | Keep the wait. The wait was guarding against an event-bus settle that the SynchronizedRef has already committed to. | +| 3 | Drop `{busy:true}` from the runner response and the gateway's retry-on-busy branch | The retry-on-busy only helps for the `Running` collision; `Shell`/`ShellThenRun` would have picked the message up anyway. For `Running` the opencode loop polls the DB each iteration, so most collisions resolve. The end-of-loop race remains either way. | Keep `{busy:true}` and the disk-resident retry. Adds complexity for a soft safety net that does not close the lossy race. | +| 4 | Uniform `application/x-ndjson` response with `start` as the first line for both stream modes | Reuses an existing event type, no schema change. Gives `stream=false` callers an explicit accepted-receipt on the wire instead of an empty body. Stream callers see no change (they already get `start` today). | Empty body for `stream=false`. Forces callers debugging via curl to infer acceptance from headers alone, and gives the protocol two distinct shapes. | +| 5 | Emit `bootstrapMemoryPaths` events inside the background task, after `res.end()` for `stream=false` | Keeps the non-stream body strictly one line. Slack/log sinks still receive the memory events because they run inside `emit()` regardless of `res.writableEnded`. | Emit them before `res.end()` for non-stream. Two or more lines in non-stream bodies; readers would need to handle a variable line count. | +| 6 | Do not add a `delayMs` field to `TriggerRequestSchema` | Existing gateway debounce in `packages/gateway/src/queue.ts` already implements per-correlation-key delay for the only current caller. A per-trigger delay is a future need without a present user. | Add `delayMs` now. YAGNI plus an extra knob to test. | +| 7 | Preserve trigger lifecycle (`startTrigger`/`endTrigger`) and Slack progress transport unchanged | This is a wire-shape and control-flow simplification, not a behavior change. | Fold lifecycle bookkeeping into the simplification. Increases regression surface for no benefit here. | +| 8 | Replace dynamic model context-limit fetching with a hardcoded `Map` constant | Only one OpenCode endpoint (`provider.list`) is involved, exactly one consumer (`emitContextProgressFromMessage`), and the production model set is small and slow-moving (`gpt-5.4`, `gpt-5.5`). Removes a 5-minute TTL cache, a warm-up `await` on every trigger, a `provider.list` round-trip per cold cache, and a test-only reset hook. | Keep dynamic fetching with a longer TTL; load from a config file. Both keep moving parts; neither matches how rarely the model set actually changes here. | +| 9 | Use `1_050_000` for both `openai/gpt-5.4` and `openai/gpt-5.5` | User-supplied value. Matches the deployed limit for both models in this environment. | Per-model distinct numbers. Not warranted today; can be split in the same one-line edit if it changes. | +| 10 | Skip context progress for models not in the constant table | Preserves the existing "no limit known → no event" branch. New models can be added in one line; until they are, the progress UI stays silent on context rather than rendering a fabricated or zero percentage. | Default to a fallback limit (e.g. 1M) for unknown models. Hides drift between agent configs and the table; better to fail visibly by absence. | + +## Implementation risks + +- **End-of-loop race in opencode `Running`:** a user message persisted during the final iteration of the in-flight `runLoop` may be missed because the exit guard uses the iteration's stale `lastUser`/`lastAssistant` snapshot. After this change the gateway no longer holds events on disk for retry. The race surface is the same as today's (`status()` is racy), but the worst-case recovery is gone. Document in the plan; mitigate later by changing opencode `ensureRunning` to enqueue work on `Running` collisions (mirror the `Shell→ShellThenRun` branch) if observed in production. +- **5xx retry path:** the only remaining queue-retain path is "runner throws" (5xx or network). Confirm the queue handler still leaves files on disk in this case and that the gateway test suite covers it. +- **Test fixtures referencing `{busy:true}`:** five known sites (`service.test.ts:335,358,392`, `app.test.ts:511,3474`). Audit for any others. +- **NDJSON-on-error responses:** 4xx/5xx keep their existing JSON bodies. The content-type mismatch (NDJSON on 200, JSON on error) is acceptable — the gateway only reads `.text()` on non-2xx — but worth noting for any future caller that expects strict uniformity. +- **`startTrigger`/`endTrigger` ordering:** after Phase 2, the abort path calls `endTrigger(prior, "aborted")` synchronously and starts the new trigger on the same fiber. Confirm there is no observable gap where the viewer sees a session with no trigger attached. +- **Model-limit drift:** the constant table can fall out of sync with `docker/opencode/config/agents/*.md` and `docker/opencode/config/opencode.json`. Today this fails silently (no `context` event for unknown models). Add a one-line note to `AGENTS.md` (or to the agent config files themselves) reminding contributors that adding a model entry to opencode configs also requires an entry in `MODEL_CONTEXT_LIMITS`. Not a hard gate; living with silent drift is acceptable because the consequence is only a missing progress event, not an incorrect one. + +## Test plan + +- `pnpm --filter @thor/runner typecheck` and runner unit tests covering: + - `stream=false` returns 200 + NDJSON + one `start` line + EOF. + - `stream=true` returns 200 + NDJSON terminated by `done`. + - `interrupt=true` against an idle session does not 503 and does not wait. + - `interrupt=true` against a busy session aborts, sends, and the prior trigger is `endTrigger`d as `aborted`. + - `interrupt=false` against any state never calls `client.session.status`. + - 4xx/5xx paths unchanged. + - `context` progress event emitted with `limit: 1_050_000` for `openai/gpt-5.4` and `openai/gpt-5.5`. + - `context` progress event suppressed for models absent from the constant (use `openai/gpt-5.4-mini`). + - No test imports `resetModelContextLimitCacheForTests`; no test fixture stubs `client.provider.list`. +- `pnpm --filter @thor/gateway typecheck` and gateway tests covering: + - 2xx with empty body and 2xx with NDJSON both call `onAccepted`. + - 4xx calls `onRejected` with the response text. + - 5xx throws; the queue handler retains the file. + - No `{busy:true}` fixtures remain. +- `pnpm --filter @thor/common typecheck` for sanity (no schema change). +- Push branch; let the opencode E2E workflow run end-to-end (`stream:true` + `parse_done` path). +- Open PR against `main` after the workflow goes green. diff --git a/packages/common/src/service-env.ts b/packages/common/src/service-env.ts index 5546cd8e..bb2b331c 100644 --- a/packages/common/src/service-env.ts +++ b/packages/common/src/service-env.ts @@ -45,7 +45,6 @@ export function loadRunnerEnv(env: EnvSource = process.env) { port: envInt(env, "PORT", 3000), opencodeUrl: envBaseUrl(env, "OPENCODE_URL", "http://127.0.0.1:4096"), opencodeConnectTimeout: envInt(env, "OPENCODE_CONNECT_TIMEOUT", 15000), - abortTimeout: envInt(env, "ABORT_TIMEOUT", 10000), sessionErrorGraceMs: envInt(env, "SESSION_ERROR_GRACE_MS", 10000), slackBotToken: envOptionalString(env, "SLACK_BOT_TOKEN") ?? "", slackApiBaseUrl: envBaseUrl(env, "SLACK_API_BASE_URL", "https://slack.com/api"), diff --git a/packages/gateway/src/app.test.ts b/packages/gateway/src/app.test.ts index 414d5c87..ee20c6f6 100644 --- a/packages/gateway/src/app.test.ts +++ b/packages/gateway/src/app.test.ts @@ -508,10 +508,7 @@ describe("gateway", () => { ); } if (url === "http://runner.test/trigger" && init?.method === "POST") { - return new Response(JSON.stringify({ busy: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return new Response("runner unavailable", { status: 500 }); } throw new Error(`Unexpected fetch: ${url}`); }); @@ -2623,7 +2620,7 @@ describe("gateway", () => { it("uses a fresh public-channel cache hit to accept app mentions without pending privacy", async () => { const fetchImpl = vi .fn() - .mockResolvedValue(new Response(JSON.stringify({ busy: false }), { status: 200 })); + .mockResolvedValue(new Response(null, { status: 200 })); await withServer(fetchImpl, async (baseUrl, queue, queueDir, slack) => { slack.conversationsInfo.mockResolvedValueOnce({ ok: true, channel: { is_private: false } }); @@ -3453,7 +3450,7 @@ describe("gateway", () => { expect(runnerBody.correlationKey).toBe("git:branch:test-repo:feature/from-slack"); }); - it("retries queued approval outcome re-entry when runner is busy", async () => { + it("acks queued approval outcome re-entry on any successful runner response", async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce( @@ -3471,9 +3468,9 @@ describe("gateway", () => { ), ) .mockResolvedValueOnce( - new Response(JSON.stringify({ busy: true }), { + new Response('{"type":"start","sessionId":"s1","resumed":true}\n', { status: 200, - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/x-ndjson" }, }), ) .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); @@ -3514,7 +3511,6 @@ describe("gateway", () => { await new Promise((resolve) => setTimeout(resolve, 50)); await queue.flush(); - await queue.flush(); }, { remoteCliHost: "remote-cli.internal", @@ -3526,7 +3522,7 @@ describe("gateway", () => { const runnerCalls = fetchImpl.mock.calls.filter( ([url]) => typeof url === "string" && url === "http://runner.test/trigger", ); - expect(runnerCalls).toHaveLength(2); + expect(runnerCalls).toHaveLength(1); const firstBody = JSON.parse(String(runnerCalls[0]?.[1]?.body)); expect(firstBody.interrupt).toBe(false); diff --git a/packages/gateway/src/app.ts b/packages/gateway/src/app.ts index 521be2e2..d5e206a1 100644 --- a/packages/gateway/src/app.ts +++ b/packages/gateway/src/app.ts @@ -1064,7 +1064,7 @@ export function createGatewayApp(config: GatewayAppConfig): GatewayApp { const hasInterrupt = events.some((event) => event.interrupt); const logTrigger = ( prefix: BatchLogPrefix, - outcome: "busy" | "dropped" | "fired", + outcome: "dropped" | "fired", reason?: string, ) => { logInfo( @@ -1152,9 +1152,7 @@ export function createGatewayApp(config: GatewayAppConfig): GatewayApp { } const result = await executeBatchDispatchPlan(plan); - if (result.busy) { - logTrigger(plan.logPrefix, "busy"); - } else if (result.rejected) { + if (result.rejected) { logTrigger(plan.logPrefix, "dropped", result.reason); } else { logTrigger(plan.logPrefix, "fired"); diff --git a/packages/gateway/src/queue.test.ts b/packages/gateway/src/queue.test.ts index d0e0b2f4..234d8fb4 100644 --- a/packages/gateway/src/queue.test.ts +++ b/packages/gateway/src/queue.test.ts @@ -259,7 +259,7 @@ describe("EventQueue", () => { it("files stay on disk when handler does not call ack", async () => { const handler = vi.fn().mockImplementation(async () => { - // Don't call ack — simulates busy/deferred + // Don't call ack — simulates deferred retry }); queue = new EventQueue({ dir: queueDir, handler, disableInterval: true }); diff --git a/packages/gateway/src/queue.ts b/packages/gateway/src/queue.ts index 614611ef..dd374131 100644 --- a/packages/gateway/src/queue.ts +++ b/packages/gateway/src/queue.ts @@ -63,7 +63,7 @@ const QueuedEventSchema = z.object({ /** * Handler callback. Call `ack()` to confirm processing and delete the files. * Call `reject(reason)` to move files to the dead-letter directory. - * If the handler returns without calling ack or reject (e.g. runner busy), + * If the handler returns without calling ack or reject (e.g. retryable runner failure), * files stay on disk and will be retried on the next scan cycle. * If the handler throws, files are deleted to prevent infinite retry loops. */ diff --git a/packages/gateway/src/service.test.ts b/packages/gateway/src/service.test.ts index 27f6825b..12370a3c 100644 --- a/packages/gateway/src/service.test.ts +++ b/packages/gateway/src/service.test.ts @@ -133,8 +133,7 @@ describe("triggerRunnerSlack edge cases", () => { onRejected, ); - expect(result.busy).toBe(false); - expect(result.rejected).toBe(true); + expect(result).toMatchObject({ rejected: true }); expect(onRejected).toHaveBeenCalledWith(expect.stringContaining("400")); }); @@ -174,7 +173,7 @@ describe("triggerRunnerCron", () => { deps, ); - expect(result.busy).toBe(false); + expect(result).toEqual({ rejected: false }); const triggerBody = JSON.parse(String(mockFetch.mock.calls[0][1]?.body)); expect(triggerBody.prompt).toBe("Cron events:\n\ndo something\n\ndo the follow-up"); }); @@ -213,7 +212,7 @@ describe("triggerRunnerGitHub", () => { vi.fn(), ); - expect(result.busy).toBe(false); + expect(result).toEqual({ rejected: false }); expect(mockFetch.mock.calls[0][0]).toBe("http://remote-cli:3004/internal/exec"); expect(mockFetch.mock.calls[0][1]).toMatchObject({ method: "POST", @@ -264,7 +263,7 @@ describe("triggerRunnerGitHub", () => { vi.fn(), ); - expect(result.busy).toBe(false); + expect(result).toEqual({ rejected: false }); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch.mock.calls[0][0]).toBe("http://runner:3000/trigger"); const triggerBody = JSON.parse(String(mockFetch.mock.calls[0][1]?.body)); @@ -297,7 +296,7 @@ describe("triggerRunnerGitHub", () => { onRejected, ); - expect(result.busy).toBe(false); + expect(result).toMatchObject({ rejected: true }); expect(onRejected).toHaveBeenCalledWith("installation_gone"); expect(mockFetch).toHaveBeenCalledTimes(1); }); @@ -326,13 +325,13 @@ describe("triggerRunnerGitHub", () => { onRejected, ); - expect(result).toEqual({ busy: false }); + expect(result).toEqual({ rejected: false }); expect(onRejected).not.toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalledTimes(2); }); - it("returns busy without ack for non-mention events", async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ busy: true })); + it("acks any successful runner response for non-mention events", async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); const onAccepted = vi.fn(); const { triggerRunnerGitHub } = await import("./service.js"); @@ -346,8 +345,8 @@ describe("triggerRunnerGitHub", () => { onAccepted, ); - expect(result.busy).toBe(true); - expect(onAccepted).not.toHaveBeenCalled(); + expect(result).toEqual({ rejected: false }); + expect(onAccepted).toHaveBeenCalled(); const triggerBody = JSON.parse(String(mockFetch.mock.calls[0][1]?.body)); expect(triggerBody.interrupt).toBe(false); }); @@ -355,7 +354,12 @@ describe("triggerRunnerGitHub", () => { describe("approval outcome prompts", () => { it("includes approval guidance when slack events and approval outcomes share a batch", async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ busy: true })); + const fetchImpl = vi.fn().mockResolvedValue( + new Response('{"type":"start","sessionId":"s1","resumed":false}\n', { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }), + ); const { triggerRunnerSlack } = await import("./service.js"); const result = await triggerRunnerSlack( @@ -389,7 +393,7 @@ describe("approval outcome prompts", () => { ], ); - expect(result.busy).toBe(true); + expect(result).toEqual({ rejected: false }); const req = fetchImpl.mock.calls[0]?.[1] as { body: string }; const body = JSON.parse(req.body); expect(body.prompt).toContain("Slack event:"); @@ -669,7 +673,7 @@ describe("triggerRunnerApprovalOutcomes", () => { ), ]); - expect(outcome).toEqual({ kind: "resolved", result: { busy: false } }); + expect(outcome).toEqual({ kind: "resolved", result: { rejected: false } }); expect(onAccepted).toHaveBeenCalledTimes(1); await resultPromise; diff --git a/packages/gateway/src/service.ts b/packages/gateway/src/service.ts index 0f806f2e..d8c03a61 100644 --- a/packages/gateway/src/service.ts +++ b/packages/gateway/src/service.ts @@ -130,14 +130,9 @@ function getFetch(fetchImpl?: typeof fetch): typeof fetch { return fetchImpl ?? fetch; } -export interface TriggerResult { - /** True when the runner reported session busy and interrupt was false. */ - busy: boolean; - /** True when the batch was terminally rejected (dead-lettered). */ - rejected?: boolean; - /** Human-readable rejection reason; set when `rejected` is true. */ - reason?: string; -} +export type TriggerResult = + | { rejected: true; reason: string } + | { rejected: false }; export interface GitHubPrHeadResult { ref: string; @@ -559,17 +554,13 @@ async function triggerRunnerPrompt(options: RunnerTriggerOptions): Promise= 400 && response.status < 500) { const reason = `Runner returned ${response.status}: ${text}`; options.onRejected?.(reason); - return { busy: false, rejected: true, reason }; + return { rejected: true, reason }; } throw new Error(`Runner returned ${response.status}: ${text}`); } - const json = (await response.json()) as Record; - if (json.busy === true) { - return { busy: true }; - } options.onAccepted?.(); - return { busy: false }; + return { rejected: false }; } export async function planBatchDispatch(input: BatchDispatchInput): Promise { @@ -758,7 +749,7 @@ async function dispatchBatch(input: BatchDispatchInput): Promise const plan = await planBatchDispatch(currentInput); if (plan.kind === "drop") { currentInput.onRejected?.(plan.reason); - return { busy: false, rejected: true, reason: plan.reason }; + return { rejected: true, reason: plan.reason }; } if (plan.kind === "reroute") { currentInput = { @@ -785,7 +776,7 @@ export async function triggerRunnerSlack( approvalOutcomes?: ApprovalOutcomeEventPayload[], ): Promise { if (events.length === 0 && (!approvalOutcomes || approvalOutcomes.length === 0)) { - return { busy: false }; + return { rejected: false }; } const handleRejected = (reason: string) => { @@ -852,7 +843,7 @@ export async function triggerRunnerGitHub( onAccepted?: () => void, onRejected?: (reason: string) => void, ): Promise { - if (events.length === 0) return { busy: false }; + if (events.length === 0) return { rejected: false }; return dispatchBatch({ slackEvents: [], @@ -885,7 +876,7 @@ export async function triggerRunnerApprovalOutcomes( slackDirectoryForChannel?: (channel: string) => SlackRoutingInfo, onRejected?: (reason: string) => void, ): Promise { - if (events.length === 0) return { busy: false }; + if (events.length === 0) return { rejected: false }; const handleRejected = (reason: string) => { const last = events[events.length - 1]; diff --git a/packages/runner/src/event-bus.test.ts b/packages/runner/src/event-bus.test.ts index 6f00b052..767c9f45 100644 --- a/packages/runner/src/event-bus.test.ts +++ b/packages/runner/src/event-bus.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import type { Event, GlobalEvent, TextPart } from "@opencode-ai/sdk"; -import { EventBusRegistry, SessionSubscription, waitForSessionSettled } from "./event-bus.js"; +import { EventBusRegistry, SessionSubscription } from "./event-bus.js"; vi.mock("@opencode-ai/sdk", () => { return { @@ -49,13 +49,6 @@ function makeIdleEvent(sessionID: string): Event { }; } -function makeErrorEvent(sessionID: string): Event { - return { - type: "session.error", - properties: { sessionID, error: { name: "UnknownError", data: { message: "test" } } }, - }; -} - function makeGlobalEvent(directory: string, event: Event): GlobalEvent { return { directory, @@ -115,10 +108,6 @@ async function collectUntilIdle(sub: AsyncIterable): Promise { return items; } -async function* eventIterable(events: Event[]) { - for (const event of events) yield event; -} - describe("SessionSubscription", () => { let emitter: EventEmitter; @@ -358,31 +347,3 @@ describe("EventBusRegistry", () => { sub.close(); }); }); - -describe("waitForSessionSettled", () => { - it("treats idle and error events as settled", async () => { - await expect(waitForSessionSettled(eventIterable([makeIdleEvent("s1")]), 1_000)).resolves.toBe( - true, - ); - await expect(waitForSessionSettled(eventIterable([makeErrorEvent("s1")]), 1_000)).resolves.toBe( - true, - ); - }); - - it("returns false when the event stream ends before the session settles", async () => { - await expect(waitForSessionSettled(eventIterable([makePartEvent("s1")]), 1_000)).resolves.toBe( - false, - ); - }); - - it("resolves false when the timeout fires before any settle event arrives", async () => { - const emitter = new EventEmitter(); - const sub = new SessionSubscription(emitter, ["s1"]); - try { - // Stream stays open with no settle event — timeout has to win. - await expect(waitForSessionSettled(sub, 30)).resolves.toBe(false); - } finally { - sub.close(); - } - }); -}); diff --git a/packages/runner/src/event-bus.ts b/packages/runner/src/event-bus.ts index 0cc2df9c..29a6b61a 100644 --- a/packages/runner/src/event-bus.ts +++ b/packages/runner/src/event-bus.ts @@ -285,38 +285,6 @@ export class SessionSubscription implements AsyncIterable { } } -/** - * Wait for a session to reach a terminal state, with a hard timeout. - * Resolves `true` if settled, `false` on timeout or subscription end. - * - * Terminal events: - * - `session.idle` — session completed successfully (no error) - * - `session.error` — session errored out (including after abort) - */ -export async function waitForSessionSettled( - sub: AsyncIterable, - timeoutMs: number, -): Promise { - const waitForSettled = (async () => { - for await (const event of sub) { - if (event.type === "session.idle" || event.type === "session.error") return true; - } - return false; - })(); - - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - waitForSettled, - new Promise((resolve) => { - timeout = setTimeout(() => resolve(false), timeoutMs); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } -} - async function closeSseResource(resource: unknown): Promise { if (!resource || typeof resource !== "object") return; for (const method of ["return", "abort", "cancel", "close"] as const) { diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts index 4e2b9667..19a8e6bc 100644 --- a/packages/runner/src/index.ts +++ b/packages/runner/src/index.ts @@ -11,7 +11,7 @@ import type { ToolStateCompleted, ToolStateError, } from "@opencode-ai/sdk"; -import { EventBusRegistry, waitForSessionSettled } from "./event-bus.js"; +import { EventBusRegistry } from "./event-bus.js"; import { readFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { @@ -84,7 +84,6 @@ const PORT = config.port; const OPENCODE_URL = config.opencodeUrl; const OPENCODE_CONNECT_TIMEOUT = config.opencodeConnectTimeout; const INTERNAL_SECRET_HEADER = "x-thor-internal-secret"; -const ABORT_TIMEOUT = config.abortTimeout; const SESSION_ERROR_GRACE_MS = config.sessionErrorGraceMs; /** Memory directory root. */ @@ -95,20 +94,10 @@ const defaultEventBuses = new EventBusRegistry(OPENCODE_URL); type OpencodeClient = ReturnType; type ModelContextLimits = Map; -const EMPTY_MODEL_CONTEXT_LIMITS: ModelContextLimits = new Map(); -const MODEL_CONTEXT_LIMIT_CACHE_TTL_MS = 5 * 60_000; -let cachedModelContextLimits: - | { - expiresAt: number; - limits: ModelContextLimits; - } - | undefined; -let cachedModelContextLimitsPending: Promise | undefined; - -export function resetModelContextLimitCacheForTests(): void { - cachedModelContextLimits = undefined; - cachedModelContextLimitsPending = undefined; -} +const MODEL_CONTEXT_LIMITS: ModelContextLimits = new Map([ + ["openai/gpt-5.4", 1_050_000], + ["openai/gpt-5.5", 1_050_000], +]); export interface RunnerAppOptions { opencodeUrl?: string; @@ -545,15 +534,14 @@ export function createRunnerApp(options: RunnerAppOptions = {}): express.Express triggerGithubLogin: z.string().trim().min(1).optional(), /** Direct session ID to resume (bypasses correlation key lookup). */ sessionId: z.string().optional(), - /** If true, abort a busy session before sending the prompt. - * Defaults to false: return {busy: true} without aborting. */ + /** If true, abort the resumed session before sending the prompt. */ interrupt: z.boolean().optional(), /** Working directory for the OpenCode session. */ directory: z.string(), /** If true, hold the HTTP response open and stream progress events as * NDJSON lines until the agent settles, ending with a `done` line. - * Default false: fire-and-forget — return {accepted,sessionId,resumed} - * immediately and run the agent in a background task. Used by the + * Default false: return one `start` NDJSON line, then run the agent in a + * background task. Used by the * OpenCode smoke test, which needs to read the agent's final response * text and status from the trigger call. */ stream: z.boolean().optional(), @@ -850,55 +838,24 @@ export function createRunnerApp(options: RunnerAppOptions = {}): express.Express const resumed = resolution.resumed; const anchorId = resolution.anchorId; - // Kick off model-limit warming up front so it overlaps the busy check and - // prompt-send. Awaited later before the stream loop reads the cache. - const warmModelLimits = warmModelContextLimits({ client, opencodeUrl }); - - // --- If resuming a busy session, abort or bail --- - if (resumed) { - const statusResult = await client.session.status({}); - const sessionStatus = statusResult.data?.[sessionId]; - - if (sessionStatus?.type === "busy") { - // Non-interrupt triggers don't abort — return busy so gateway can re-enqueue. - const shouldInterrupt = parsed.data.interrupt === true; - if (!shouldInterrupt) { - logInfo(log, "session_busy_nointerrupt", { sessionId, correlationKey }); - res.json({ busy: true }); - return; - } - - // End any in-flight trigger this process owns for the session before aborting, - // so the prior trigger renders as `aborted` rather than `completed`. - const priorTriggerId = findInflightTriggerForSession(sessionId); - if (priorTriggerId) { - endTrigger(priorTriggerId, "aborted", { reason: "user_interrupt" }); - } - - logInfo(log, "session_busy_aborting", { sessionId, correlationKey }); - await client.session.abort({ path: { id: sessionId } }); - - const abortSub = await eventBuses.subscribe([sessionId]); - const aborted = await waitForSessionSettled(abortSub, ABORT_TIMEOUT); - abortSub.close(); - - if (!aborted) { - logError( - log, - "session_abort_timeout", - `Session did not idle within ${ABORT_TIMEOUT}ms`, - { sessionId }, - ); - res.status(503).json({ error: "Session abort did not settle", sessionId }); - return; - } - logInfo(log, "session_abort_complete", { sessionId }); + // --- If requested, abort a resumed session before prompting. --- + if (resumed && parsed.data.interrupt === true) { + // End any in-flight trigger this process owns for the session before aborting, + // so the prior trigger renders as `aborted` rather than `completed`. + const priorTriggerId = findInflightTriggerForSession(sessionId); + if (priorTriggerId) { + endTrigger(priorTriggerId, "aborted", { reason: "user_interrupt" }); } - } - // Block briefly so the first trigger after process start sees populated - // limits; subsequent calls within the cache TTL resolve immediately. - await warmModelLimits; + logInfo(log, "session_interrupt_aborting", { sessionId, correlationKey }); + await client.session.abort({ path: { id: sessionId } }).catch((error) => { + logWarn(log, "session_interrupt_abort_failed", { + sessionId, + correlationKey, + error: error instanceof Error ? error.message : String(error), + }); + }); + } const bootstrapMemoryPaths: string[] = []; @@ -981,10 +938,8 @@ export function createRunnerApp(options: RunnerAppOptions = {}): express.Express let progressChain = Promise.resolve(); const stream = parsed.data.stream === true; - if (stream) { - res.setHeader("Content-Type", "application/x-ndjson"); - res.flushHeaders?.(); - } + res.setHeader("Content-Type", "application/x-ndjson"); + res.flushHeaders?.(); function emit(event: ProgressEvent): void { logInfo(log, "progress_emit", { @@ -1010,7 +965,7 @@ export function createRunnerApp(options: RunnerAppOptions = {}): express.Express ts: Date.now(), }); options.progressEventSink?.(event); - if (stream && !res.writableEnded) { + if (!res.writableEnded) { res.write(JSON.stringify(event) + "\n"); } if (!progressTarget || !progressTransport) return; @@ -1032,12 +987,12 @@ export function createRunnerApp(options: RunnerAppOptions = {}): express.Express resumed, }); - for (const path of bootstrapMemoryPaths) { - emit({ type: "memory", action: "read", path, source: "bootstrap" }); - } - - const backgroundTask = (async () => { + const runBackgroundTask = async () => { try { + for (const path of bootstrapMemoryPaths) { + emit({ type: "memory", action: "read", path, source: "bootstrap" }); + } + // --- Stream processing --- let seq = 0; @@ -1121,7 +1076,7 @@ export function createRunnerApp(options: RunnerAppOptions = {}): express.Express const isParent = isSessionEvent(event, sessionId); if (isParent && event.type === "message.updated") { - emitContextProgressFromMessage(event, currentModelContextLimits(), emit); + emitContextProgressFromMessage(event, MODEL_CONTEXT_LIMITS, emit); } // Forward tool progress from child sessions so @@ -1301,13 +1256,14 @@ export function createRunnerApp(options: RunnerAppOptions = {}): express.Express emit({ type: "error", error: err instanceof Error ? err.message : String(err) }); await progressChain; } - })(); + }; if (stream) { + const backgroundTask = runBackgroundTask(); await backgroundTask; if (!res.writableEnded) res.end(); } else { - void backgroundTask; - res.json({ accepted: true, sessionId, resumed }); + if (!res.writableEnded) res.end(); + void runBackgroundTask(); } } catch (err) { logError(log, "trigger_error", err); @@ -1351,59 +1307,6 @@ function contextLimitKey(providerID: string, modelID: string): string { return `${providerID}/${modelID}`; } -async function resolveModelContextLimits(client: OpencodeClient): Promise { - const limits: ModelContextLimits = new Map(); - try { - const { data } = await client.provider.list({}); - for (const provider of data?.all ?? []) { - for (const [modelID, model] of Object.entries(provider.models)) { - if (model.limit.context > 0) { - limits.set(contextLimitKey(provider.id, modelID), Math.floor(model.limit.context)); - } - } - } - } catch (err) { - logWarn(log, "model_context_limits_load_failed", { - error: err instanceof Error ? err.message : String(err), - }); - } - return limits; -} - -function currentModelContextLimits(): ModelContextLimits { - const cached = cachedModelContextLimits; - if (!cached) return EMPTY_MODEL_CONTEXT_LIMITS; - if (cached.expiresAt <= Date.now()) return EMPTY_MODEL_CONTEXT_LIMITS; - return cached.limits; -} - -function warmModelContextLimits(input: { - client: OpencodeClient; - opencodeUrl: string; -}): Promise { - const cached = cachedModelContextLimits; - if (cached && cached.expiresAt > Date.now()) return Promise.resolve(); - if (cachedModelContextLimitsPending) return cachedModelContextLimitsPending; - - cachedModelContextLimitsPending = resolveModelContextLimits(input.client) - .then((limits) => { - cachedModelContextLimits = { - limits, - expiresAt: Date.now() + MODEL_CONTEXT_LIMIT_CACHE_TTL_MS, - }; - }) - .catch((err) => { - logWarn(log, "model_context_limits_warm_failed", { - opencodeUrl: input.opencodeUrl, - error: err instanceof Error ? err.message : String(err), - }); - }) - .finally(() => { - cachedModelContextLimitsPending = undefined; - }); - return cachedModelContextLimitsPending; -} - function messageUpdatedInfo(event: Event): Record | undefined { const properties = (event as unknown as { properties?: unknown }).properties; if (!isRecord(properties)) return undefined; diff --git a/packages/runner/src/trigger.test.ts b/packages/runner/src/trigger.test.ts index 011f0766..893d5d40 100644 --- a/packages/runner/src/trigger.test.ts +++ b/packages/runner/src/trigger.test.ts @@ -3,11 +3,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import type { Event, TextPart } from "@opencode-ai/sdk"; -import { - createRunnerApp, - resetModelContextLimitCacheForTests, - type RunnerAppOptions, -} from "./index.js"; +import { createRunnerApp, type RunnerAppOptions } from "./index.js"; import { appendAlias, appendCorrelationAliasForAnchor, @@ -204,15 +200,14 @@ function sessionErrorEvent(sessionId: string, message: string): Event { function createHarness( opts: { + abortError?: Error; existingSessions?: Set; busySessions?: Set; children?: Array<{ id: string }>; onGet?: (sessionId: string) => Promise; - onProviderList?: () => void; promptEvents?: (sessionId: string, sub: FakeSubscription) => Event[] | void; throwInSubscribe?: boolean; workspaceConfig?: WorkspaceConfig; - providerList?: unknown; opencodeUrl?: string; } = {}, ) { @@ -221,6 +216,7 @@ function createHarness( const busySessions = opts.busySessions ?? new Set(); const prompts: string[] = []; const aborts: string[] = []; + const statusCalls: string[] = []; const progressEvents: unknown[] = []; const abortedPending = new Set(); let counter = 0; @@ -237,11 +233,15 @@ function createHarness( if (!existingSessions.has(path.id)) throw new Error("missing"); return { data: { id: path.id } }; }, - status: async () => ({ - data: Object.fromEntries([...busySessions].map((id) => [id, { type: "busy" }])), - }), + status: async () => { + statusCalls.push("status"); + return { + data: Object.fromEntries([...busySessions].map((id) => [id, { type: "busy" }])), + }; + }, abort: async ({ path }: { path: { id: string } }) => { aborts.push(path.id); + if (opts.abortError) throw opts.abortError; busySessions.delete(path.id); abortedPending.add(path.id); return { data: {} }; @@ -266,12 +266,6 @@ function createHarness( }, children: async () => ({ data: opts.children ?? [] }), }, - provider: { - list: async () => { - opts.onProviderList?.(); - return { data: opts.providerList ?? { all: [], default: {}, connected: [] } }; - }, - }, }; const app = createRunnerApp({ @@ -302,7 +296,7 @@ function createHarness( }); latestProgressEvents = progressEvents; - return { app, prompts, aborts, existingSessions, busySessions, progressEvents }; + return { app, prompts, aborts, statusCalls, existingSessions, busySessions, progressEvents }; } let latestProgressEvents: unknown[] = []; @@ -329,27 +323,28 @@ async function trigger(url: string, body: Record) { body: JSON.stringify({ directory: sessionDir, ...body }), }); const text = await response.text(); - const json = text.trim() ? JSON.parse(text) : undefined; - let events = text + const responseEvents = text .trim() .split("\n") .filter(Boolean) .map((line) => JSON.parse(line)); - if (events.length === 1 && events[0]?.accepted === true) { - const deadline = Date.now() + 100; - do { - events = latestProgressEvents.slice(progressOffset); - if (events.some((event) => (event as { type?: string }).type === "done")) break; - await new Promise((resolve) => setTimeout(resolve, 1)); - } while (Date.now() < deadline); - } - return { response, json, events }; + const json = responseEvents.length === 1 ? responseEvents[0] : undefined; + let events = responseEvents; + const deadline = Date.now() + 100; + do { + const progressEvents = latestProgressEvents.slice(progressOffset); + if (progressEvents.some((event) => (event as { type?: string }).type === "done")) { + events = progressEvents; + break; + } + await new Promise((resolve) => setTimeout(resolve, 1)); + } while (Date.now() < deadline); + return { response, json, events, responseEvents, text }; } beforeEach(() => { process.env.WORKLOG_DIR = worklogDir; rmSync("/tmp/thor-runner-trigger-test", { recursive: true, force: true }); - resetModelContextLimitCacheForTests(); }); afterEach(() => { @@ -1067,7 +1062,7 @@ describe("runner /trigger orchestration", () => { }); }); - it("returns busy without prompting when a resumed session is busy and interrupt is absent", async () => { + it("prompts a resumed session without status check when interrupt is absent", async () => { const h = createHarness({ existingSessions: new Set(["busy-session"]), busySessions: new Set(["busy-session"]), @@ -1075,20 +1070,20 @@ describe("runner /trigger orchestration", () => { setupBusySession("1710000000.003"); await withServer(h.app, async (url) => { - const response = await fetch(`${url}/trigger`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - prompt: "later", - correlationKey: "slack:thread:C123/1710000000.003", - directory: sessionDir, - }), + const result = await trigger(url, { + prompt: "later", + correlationKey: "slack:thread:C123/1710000000.003", }); - expect(await response.json()).toEqual({ busy: true }); + expect(result.response.status).toBe(200); + expect(result.response.headers.get("content-type")).toContain("application/x-ndjson"); + expect(result.responseEvents).toEqual([ + expect.objectContaining({ type: "start", sessionId: "busy-session", resumed: true }), + ]); }); expect(h.aborts).toHaveLength(0); - expect(h.prompts).toHaveLength(0); + expect(h.statusCalls).toHaveLength(0); + expect(h.prompts).toHaveLength(1); }); it("aborts then prompts when a resumed session is busy and interrupt is true", async () => { @@ -1111,6 +1106,33 @@ describe("runner /trigger orchestration", () => { }); }); + expect(h.statusCalls).toHaveLength(0); + expect(h.aborts).toEqual(["busy-session"]); + expect(h.prompts).toHaveLength(1); + }); + + it("still prompts when interrupt abort rejects", async () => { + const h = createHarness({ + abortError: new Error("abort failed"), + existingSessions: new Set(["busy-session"]), + busySessions: new Set(["busy-session"]), + }); + setupBusySession("1710000000.013"); + + await withServer(h.app, async (url) => { + const result = await trigger(url, { + prompt: "now", + correlationKey: "slack:thread:C123/1710000000.013", + interrupt: true, + }); + expect(result.events.find((e) => e.type === "done")).toMatchObject({ + sessionId: "busy-session", + resumed: true, + status: "completed", + }); + }); + + expect(h.statusCalls).toHaveLength(0); expect(h.aborts).toEqual(["busy-session"]); expect(h.prompts).toHaveLength(1); }); @@ -1153,11 +1175,12 @@ describe("runner /trigger orchestration", () => { }); }); + expect(h.statusCalls).toHaveLength(0); expect(h.aborts).toEqual(["busy-session"]); expect(h.prompts).toHaveLength(1); }); - it("returns busy without prompting when a resumed session is busy and interrupt is false", async () => { + it("prompts a resumed session without aborting when interrupt is false", async () => { const h = createHarness({ existingSessions: new Set(["busy-session"]), busySessions: new Set(["busy-session"]), @@ -1165,20 +1188,19 @@ describe("runner /trigger orchestration", () => { setupBusySession("1710000000.011"); await withServer(h.app, async (url) => { - const response = await fetch(`${url}/trigger`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - prompt: "later", - correlationKey: "slack:thread:C123/1710000000.011", - directory: sessionDir, - interrupt: false, - }), + const result = await trigger(url, { + prompt: "later", + correlationKey: "slack:thread:C123/1710000000.011", + interrupt: false, }); - expect(await response.json()).toEqual({ busy: true }); + expect(result.responseEvents).toEqual([ + expect.objectContaining({ type: "start", sessionId: "busy-session", resumed: true }), + ]); }); - expect(h.prompts).toHaveLength(0); + expect(h.statusCalls).toHaveLength(0); + expect(h.aborts).toHaveLength(0); + expect(h.prompts).toHaveLength(1); }); it("injects memory/tool bootstrap instructions only on new sessions", async () => { @@ -1221,18 +1243,6 @@ describe("runner /trigger orchestration", () => { it("emits context progress from assistant message updates using configured model limits", async () => { const h = createHarness({ - providerList: { - all: [ - { - id: "openai", - models: { - "gpt-5.5": { limit: { context: 200_000 } }, - }, - }, - ], - default: {}, - connected: [], - }, promptEvents: (sessionId) => [ messageUpdatedEvent(sessionId), textEvent(sessionId, "done"), @@ -1251,58 +1261,15 @@ describe("runner /trigger orchestration", () => { providerID: "openai", modelID: "gpt-5.5", tokens: 126_000, - limit: 200_000, - usagePercent: 63, + limit: 1_050_000, + usagePercent: 12, }); expect(result.events.filter((e) => e.type === "tool")).toHaveLength(0); }); }); - it("keys context limits by provider and model to avoid same-model collisions", async () => { - const h = createHarness({ - providerList: { - all: [ - { id: "openai", models: { "gpt-5.4": { limit: { context: 200_000 } } } }, - { id: "anthropic", models: { "gpt-5.4": { limit: { context: 1_000_000 } } } }, - ], - default: {}, - connected: [], - }, - promptEvents: (sessionId) => [ - messageUpdatedEvent(sessionId, { - providerID: "openai", - modelID: "gpt-5.4", - tokens: { input: 100_000, output: 20_000, reasoning: 6_000 }, - role: "assistant", - }), - textEvent(sessionId, "done"), - idleEvent(sessionId), - ], - }); - - await withServer(h.app, async (url) => { - const result = await trigger(url, { - prompt: "large search", - correlationKey: "slack:thread:1710000000.099", - }); - - expect(result.events.find((e) => e.type === "context")).toMatchObject({ - providerID: "openai", - modelID: "gpt-5.4", - tokens: 126_000, - limit: 200_000, - usagePercent: 63, - }); - }); - }); - it("extracts context totals only from displayed token usage fields", async () => { const h = createHarness({ - providerList: { - all: [{ id: "openai", models: { "gpt-5.5": { limit: { context: 200_000 } } } }], - default: {}, - connected: [], - }, promptEvents: (sessionId) => [ messageUpdatedEvent(sessionId, { providerID: "openai", @@ -1329,19 +1296,14 @@ describe("runner /trigger orchestration", () => { expect(result.events.find((e) => e.type === "context")).toMatchObject({ tokens: 130_000, - limit: 200_000, - usagePercent: 65, + limit: 1_050_000, + usagePercent: 12, }); }); }); it("suppresses zero-token assistant message context updates", async () => { const h = createHarness({ - providerList: { - all: [{ id: "openai", models: { "gpt-5.5": { limit: { context: 200_000 } } } }], - default: {}, - connected: [], - }, promptEvents: (sessionId) => [ messageUpdatedEvent(sessionId, { providerID: "openai", @@ -1366,18 +1328,6 @@ describe("runner /trigger orchestration", () => { it("normalizes context usage percent to an integer before emitting", async () => { const h = createHarness({ - providerList: { - all: [ - { - id: "openai", - models: { - "gpt-5.5": { limit: { context: 200_000 } }, - }, - }, - ], - default: {}, - connected: [], - }, promptEvents: (sessionId) => [ messageUpdatedEvent(sessionId, { providerID: "openai", @@ -1401,141 +1351,21 @@ describe("runner /trigger orchestration", () => { providerID: "openai", modelID: "gpt-5.5", tokens: 99_999, - limit: 200_000, - usagePercent: 50, + limit: 1_050_000, + usagePercent: 10, }); }); }); - it("caches resolved model context limits in memory across triggers", async () => { - let providerLists = 0; + it("skips context progress for models not in the constant table", async () => { const h = createHarness({ - onProviderList: () => { - providerLists++; - }, - providerList: { - all: [ - { - id: "openai", - models: { - "gpt-5.5": { limit: { context: 200_000 } }, - }, - }, - ], - default: {}, - connected: [], - }, promptEvents: (sessionId) => [ - messageUpdatedEvent(sessionId), - textEvent(sessionId, "done"), - idleEvent(sessionId), - ], - }); - - await withServer(h.app, async (url) => { - await trigger(url, { - prompt: "large search one", - correlationKey: "slack:thread:1710000000.093", - }); - await trigger(url, { - prompt: "large search two", - correlationKey: "slack:thread:1710000000.094", - }); - }); - - expect(providerLists).toBe(1); - }); - - it("shares the global model-limit cache across opencode urls", async () => { - let providerListsA = 0; - let providerListsB = 0; - const promptEvents = (sessionId: string) => [ - messageUpdatedEvent(sessionId), - textEvent(sessionId, "done"), - idleEvent(sessionId), - ]; - - const a = createHarness({ - opencodeUrl: "http://opencode-a.test:4096", - onProviderList: () => { - providerListsA++; - }, - providerList: { - all: [{ id: "openai", models: { "gpt-5.5": { limit: { context: 200_000 } } } }], - default: {}, - connected: [], - }, - promptEvents, - }); - const b = createHarness({ - opencodeUrl: "http://opencode-b.test:4096", - onProviderList: () => { - providerListsB++; - }, - providerList: { - all: [{ id: "openai", models: { "gpt-5.5": { limit: { context: 200_000 } } } }], - default: {}, - connected: [], - }, - promptEvents, - }); - - await withServer(a.app, async (urlA) => { - await withServer(b.app, async (urlB) => { - await Promise.all([ - trigger(urlA, { - prompt: "large search a", - correlationKey: "slack:thread:1710000000.095", - }), - trigger(urlB, { - prompt: "large search b", - correlationKey: "slack:thread:1710000000.096", - }), - ]); - }); - }); - - expect(providerListsA + providerListsB).toBe(1); - }); - - it("warms model limits best-effort even when a resumed session returns busy", async () => { - let providerLists = 0; - const h = createHarness({ - existingSessions: new Set(["busy-session"]), - busySessions: new Set(["busy-session"]), - onProviderList: () => { - providerLists++; - }, - }); - - await withServer(h.app, async (url) => { - const response = await fetch(`${url}/trigger`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - prompt: "hello", - sessionId: "busy-session", - correlationKey: "slack:thread:1710000000.097", - directory: "/workspace/repos/runner-trigger-test", + messageUpdatedEvent(sessionId, { + providerID: "openai", + modelID: "gpt-5.4-mini", + tokens: { input: 100_000, output: 20_000, reasoning: 6_000 }, + role: "assistant", }), - }); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ busy: true }); - }); - - expect(providerLists).toBe(1); - }); - - it("skips context progress when no positive configured model limit is known", async () => { - const h = createHarness({ - providerList: { - all: [{ id: "openai", models: { "gpt-5.5": { limit: { context: 0 } } } }], - default: {}, - connected: [], - }, - promptEvents: (sessionId) => [ - messageUpdatedEvent(sessionId), textEvent(sessionId, "done"), idleEvent(sessionId), ], @@ -1551,24 +1381,8 @@ describe("runner /trigger orchestration", () => { }); }); - it("warms model limits best-effort even for tokenless message.updated events", async () => { - let providerLists = 0; + it("skips context progress for tokenless message.updated events", async () => { const h = createHarness({ - onProviderList: () => { - providerLists++; - }, - providerList: { - all: [ - { - id: "openai", - models: { - "gpt-5.5": { limit: { context: 200_000 } }, - }, - }, - ], - default: {}, - connected: [], - }, promptEvents: (sessionId) => [ messageUpdatedEvent(sessionId, { providerID: "openai", @@ -1590,7 +1404,6 @@ describe("runner /trigger orchestration", () => { expect(result.events.find((e) => e.type === "context")).toBeUndefined(); }); - expect(providerLists).toBe(1); }); it("emits opencode.subsession aliases for discovered child sessions", async () => { From 1017aa225e0ba8dbfd4a8816cc8d42e70dc43cda Mon Sep 17 00:00:00 2001 From: "i-am-thor[bot]" <276291138+i-am-thor[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 17:28:25 +0000 Subject: [PATCH 2/4] fix: cancel runner success bodies Co-authored-by: Son Dao --- packages/gateway/src/service.test.ts | 21 ++++++++++++++++++++- packages/gateway/src/service.ts | 9 +++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/gateway/src/service.test.ts b/packages/gateway/src/service.test.ts index 12370a3c..7a1e13e1 100644 --- a/packages/gateway/src/service.test.ts +++ b/packages/gateway/src/service.test.ts @@ -33,6 +33,23 @@ function execResponse(stdout: unknown, stderr = "", exitCode = 0): Response { }); } +function ndjsonResponseWithCancelSpy(cancelSpy: ReturnType): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"type":"start"}\n')); + }, + cancel(reason) { + cancelSpy(reason); + }, + }), + { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }, + ); +} + function noopSlackDeps(): SlackDeps { return { client: {} } as unknown as SlackDeps; } @@ -331,7 +348,8 @@ describe("triggerRunnerGitHub", () => { }); it("acks any successful runner response for non-mention events", async () => { - mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + const cancelSpy = vi.fn(); + mockFetch.mockResolvedValueOnce(ndjsonResponseWithCancelSpy(cancelSpy)); const onAccepted = vi.fn(); const { triggerRunnerGitHub } = await import("./service.js"); @@ -347,6 +365,7 @@ describe("triggerRunnerGitHub", () => { expect(result).toEqual({ rejected: false }); expect(onAccepted).toHaveBeenCalled(); + expect(cancelSpy).toHaveBeenCalledTimes(1); const triggerBody = JSON.parse(String(mockFetch.mock.calls[0][1]?.body)); expect(triggerBody.interrupt).toBe(false); }); diff --git a/packages/gateway/src/service.ts b/packages/gateway/src/service.ts index d8c03a61..2b468173 100644 --- a/packages/gateway/src/service.ts +++ b/packages/gateway/src/service.ts @@ -559,6 +559,15 @@ async function triggerRunnerPrompt(options: RunnerTriggerOptions): Promise { + logWarn(log, "runner_response_cancel_failed", { + correlationKey: options.correlationKey, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + options.onAccepted?.(); return { rejected: false }; } From 57f6421d826cf8eec08ee6765bc8447e68956e73 Mon Sep 17 00:00:00 2001 From: "i-am-thor[bot]" <276291138+i-am-thor[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 17:50:17 +0000 Subject: [PATCH 3/4] docs: scrub opencode source reference Co-authored-by: Son Dao --- docs/plan/2026052702_simplify-opencode-prompt-send.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plan/2026052702_simplify-opencode-prompt-send.md b/docs/plan/2026052702_simplify-opencode-prompt-send.md index 094e1bd5..18a60a54 100644 --- a/docs/plan/2026052702_simplify-opencode-prompt-send.md +++ b/docs/plan/2026052702_simplify-opencode-prompt-send.md @@ -50,7 +50,7 @@ Additionally in scope (model-limit simplification, see Phase 5 below): - `emit()` (`:989-1026`) gates `res.write()` on the `stream` flag at `:1013`. The `start` event is already constructed at `:1028-1033` but never reaches the wire in fire-and-forget mode. - Gateway consumer: `packages/gateway/src/service.ts:543-573`. Reads `json.busy`; that is the only field it consumes from the success body. Confirmed by grep — no callsite reads `accepted`, `sessionId`, or `resumed` from the gateway side. - Dispatch handler busy-log: `packages/gateway/src/app.ts:1154-1161`. The `result.busy` branch returns without calling `ack()`; that is what causes the queue to retain the file for the next scan (`packages/gateway/src/queue.ts:70-74`). -- Verified behavior of opencode (from reading `/Users/son.dao/repos/daohoangson/opencode`): +- Verified behavior of opencode (from upstream source at https://github.com/anomalyco/opencode): - `Session.cancel` is a safe no-op when the runner is idle (`packages/opencode/src/session/run-state.ts:80-82`). - `ensureRunning` does **not** queue `work` on a `Running` collision — the new `work` is discarded and the caller attaches to the in-flight deferred (`packages/opencode/src/effect/runner.ts:120-122`). Pickup of the new user message depends on the loop's `MessageV2.filterCompactedEffect` re-read each iteration; the exit guard at `prompt.ts:1268-1276` uses the iteration's stale snapshot, so a message persisted during the final iteration can be missed. - `Shell` and `ShellThenRun` are safe collisions in practice: a fresh `runLoop` runs later and reads the DB, picking up new user messages. From 11ba7a017417aecf6c7f15ffd1e96ba89f0d6168 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 14:52:33 +0000 Subject: [PATCH 4/4] fix: resolve merge conflicts with main branch --- packages/gateway/src/service.test.ts | 5 - packages/runner/src/event-bus.test.ts | 6 +- packages/runner/src/index.ts | 6 +- packages/runner/src/trigger.test.ts | 154 +------------------------- 4 files changed, 5 insertions(+), 166 deletions(-) diff --git a/packages/gateway/src/service.test.ts b/packages/gateway/src/service.test.ts index 8df77f74..cd0d69d1 100644 --- a/packages/gateway/src/service.test.ts +++ b/packages/gateway/src/service.test.ts @@ -373,18 +373,13 @@ describe("triggerRunnerGitHub", () => { describe("approval outcome prompts", () => { it("includes approval guidance when slack events and approval outcomes share a batch", async () => { -<<<<<<< HEAD const fetchImpl = vi.fn().mockResolvedValue( new Response('{"type":"start","sessionId":"s1","resumed":false}\n', { status: 200, headers: { "content-type": "application/x-ndjson" }, }), ); - const { triggerRunnerSlack } = await import("./service.js"); -======= - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ busy: true })); const { triggerRunnerSlack } = await import("./service.ts"); ->>>>>>> origin/main const result = await triggerRunnerSlack( [ diff --git a/packages/runner/src/event-bus.test.ts b/packages/runner/src/event-bus.test.ts index 8801a10c..e0b3b230 100644 --- a/packages/runner/src/event-bus.test.ts +++ b/packages/runner/src/event-bus.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; import type { Event, GlobalEvent, TextPart } from "@opencode-ai/sdk"; -<<<<<<< HEAD -import { EventBusRegistry, SessionSubscription } from "./event-bus.js"; -======= -import { EventBusRegistry, SessionSubscription, waitForSessionSettled } from "./event-bus.ts"; ->>>>>>> origin/main +import { EventBusRegistry, SessionSubscription } from "./event-bus.ts"; vi.mock("@opencode-ai/sdk", () => { return { diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts index 5ca11ba3..f227b3bb 100644 --- a/packages/runner/src/index.ts +++ b/packages/runner/src/index.ts @@ -11,11 +11,7 @@ import type { ToolStateCompleted, ToolStateError, } from "@opencode-ai/sdk"; -<<<<<<< HEAD -import { EventBusRegistry } from "./event-bus.js"; -======= -import { EventBusRegistry, waitForSessionSettled } from "./event-bus.ts"; ->>>>>>> origin/main +import { EventBusRegistry } from "./event-bus.ts"; import { readFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { diff --git a/packages/runner/src/trigger.test.ts b/packages/runner/src/trigger.test.ts index f9788caa..98c8f0e1 100644 --- a/packages/runner/src/trigger.test.ts +++ b/packages/runner/src/trigger.test.ts @@ -3,15 +3,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import type { Event, TextPart } from "@opencode-ai/sdk"; -<<<<<<< HEAD -import { createRunnerApp, type RunnerAppOptions } from "./index.js"; -======= -import { - createRunnerApp, - resetModelContextLimitCacheForTests, - type RunnerAppOptions, -} from "./index.ts"; ->>>>>>> origin/main +import { createRunnerApp, type RunnerAppOptions } from "./index.ts"; import { appendAlias, appendCorrelationAliasForAnchor, @@ -1201,9 +1193,6 @@ describe("runner /trigger orchestration", () => { expect(h.prompts).toHaveLength(1); }); -<<<<<<< HEAD - it("prompts a resumed session without aborting when interrupt is false", async () => { -======= it("labels python3 bash wrappers with one segment only", async () => { const h = createHarness({ promptEvents: (sessionId) => [ @@ -1222,7 +1211,7 @@ describe("runner /trigger orchestration", () => { await withServer(h.app, async (url) => { const result = await trigger(url, { prompt: "now", - correlationKey: "slack:thread:C123/1710000000.013", + correlationKey: "slack:thread:C123/1710000000.014", }); expect(result.events.find((e) => e.type === "tool")).toMatchObject({ @@ -1233,8 +1222,7 @@ describe("runner /trigger orchestration", () => { }); }); - it("returns busy without prompting when a resumed session is busy and interrupt is false", async () => { ->>>>>>> origin/main + it("prompts a resumed session without aborting when interrupt is false", async () => { const h = createHarness({ existingSessions: new Set(["busy-session"]), busySessions: new Set(["busy-session"]), @@ -1322,47 +1310,6 @@ describe("runner /trigger orchestration", () => { }); }); -<<<<<<< HEAD -======= - it("keys context limits by provider and model to avoid same-model collisions", async () => { - const h = createHarness({ - providerList: { - all: [ - { id: "openai", models: { "gpt-5.4": { limit: { context: 200_000 } } } }, - { id: "anthropic", models: { "gpt-5.4": { limit: { context: 1_000_000 } } } }, - ], - default: {}, - connected: [], - }, - promptEvents: (sessionId) => [ - messageUpdatedEvent(sessionId, { - providerID: "openai", - modelID: "gpt-5.4", - tokens: { input: 100_000, output: 20_000, reasoning: 6_000 }, - role: "assistant", - }), - textEvent(sessionId, "done"), - idleEvent(sessionId), - ], - }); - - await withServer(h.app, async (url) => { - const result = await trigger(url, { - prompt: "large search", - correlationKey: "slack:thread:C0/1710000000.099", - }); - - expect(result.events.find((e) => e.type === "context")).toMatchObject({ - providerID: "openai", - modelID: "gpt-5.4", - tokens: 126_000, - limit: 200_000, - usagePercent: 63, - }); - }); - }); - ->>>>>>> origin/main it("extracts context totals only from displayed token usage fields", async () => { const h = createHarness({ promptEvents: (sessionId) => [ @@ -1455,105 +1402,11 @@ describe("runner /trigger orchestration", () => { it("skips context progress for models not in the constant table", async () => { const h = createHarness({ promptEvents: (sessionId) => [ -<<<<<<< HEAD messageUpdatedEvent(sessionId, { providerID: "openai", modelID: "gpt-5.4-mini", tokens: { input: 100_000, output: 20_000, reasoning: 6_000 }, role: "assistant", -======= - messageUpdatedEvent(sessionId), - textEvent(sessionId, "done"), - idleEvent(sessionId), - ], - }); - - await withServer(h.app, async (url) => { - await trigger(url, { - prompt: "large search one", - correlationKey: "slack:thread:C0/1710000000.093", - }); - await trigger(url, { - prompt: "large search two", - correlationKey: "slack:thread:C0/1710000000.094", - }); - }); - - expect(providerLists).toBe(1); - }); - - it("shares the global model-limit cache across opencode urls", async () => { - let providerListsA = 0; - let providerListsB = 0; - const promptEvents = (sessionId: string) => [ - messageUpdatedEvent(sessionId), - textEvent(sessionId, "done"), - idleEvent(sessionId), - ]; - - const a = createHarness({ - opencodeUrl: "http://opencode-a.test:4096", - onProviderList: () => { - providerListsA++; - }, - providerList: { - all: [{ id: "openai", models: { "gpt-5.5": { limit: { context: 200_000 } } } }], - default: {}, - connected: [], - }, - promptEvents, - }); - const b = createHarness({ - opencodeUrl: "http://opencode-b.test:4096", - onProviderList: () => { - providerListsB++; - }, - providerList: { - all: [{ id: "openai", models: { "gpt-5.5": { limit: { context: 200_000 } } } }], - default: {}, - connected: [], - }, - promptEvents, - }); - - await withServer(a.app, async (urlA) => { - await withServer(b.app, async (urlB) => { - await Promise.all([ - trigger(urlA, { - prompt: "large search a", - correlationKey: "slack:thread:C0/1710000000.095", - }), - trigger(urlB, { - prompt: "large search b", - correlationKey: "slack:thread:C0/1710000000.096", - }), - ]); - }); - }); - - expect(providerListsA + providerListsB).toBe(1); - }); - - it("warms model limits best-effort even when a resumed session returns busy", async () => { - let providerLists = 0; - const h = createHarness({ - existingSessions: new Set(["busy-session"]), - busySessions: new Set(["busy-session"]), - onProviderList: () => { - providerLists++; - }, - }); - - await withServer(h.app, async (url) => { - const response = await fetch(`${url}/trigger`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - prompt: "hello", - sessionId: "busy-session", - correlationKey: "slack:thread:C0/1710000000.097", - directory: "/workspace/repos/runner-trigger-test", ->>>>>>> origin/main }), textEvent(sessionId, "done"), idleEvent(sessionId), @@ -1592,7 +1445,6 @@ describe("runner /trigger orchestration", () => { expect(result.events.find((e) => e.type === "context")).toBeUndefined(); }); - }); it("emits opencode.subsession aliases for discovered child sessions", async () => {