Skip to content

feat(cli): add hosted workspaces and cloud system feeds - #15509

Merged
eshurakov merged 10 commits into
mainfrom
eshurakov/cloud-agent-hosted-workspaces
Oct 27, 2026
Merged

eshurakov merged 10 commits into
mainfrom
eshurakov/cloud-agent-hosted-workspaces

Conversation

@eshurakov

@eshurakov eshurakov commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Task Board

This PR is part of the Kilo Task Board and implements task-board issue #51.

Issue

No existing issue is linked. This is the requested client portion of the cross-repository Cloud Agent facade work.

Cloud implementation: Kilo-Org/cloud#8972
Local-stack integration coverage: Kilo-Org/cloud#8973

Context

Local Kilo exposed hosted sessions but not hosted workspace operations or the Cloud preparation/sandbox feed. Hosted requests also need the current source-to-control token exchange instead of sending the original user credential to the Worker.

Implementation

  • Extend the existing provider-neutral SessionHost API with workspace listing, creation, detail, and starting another session in a workspace. Keep provider-private routing IDs and credentials inside the adapter.
  • Exchange the original user source bearer at the matching web issuer. Cache short-lived control tokens in bounded memory and send only those tokens to the Worker. Validate the issuer's ISO expiresAt; do not change normal authentication or fall back after denial.
  • Register GET /cloud-agent/event in the actual HTTP API and route it through opaque hosted attachments. Keep both native and system SSE requests long-lived; reject unbound system-feed requests instead of running locally.
  • Make dev:local set the Worker and issuer origins explicitly and fail if the local Worker port is missing.
  • Regenerate the additive SDK/OpenAPI changes and add a CLI minor changeset.

Screenshots / Video

N/A: API and transport changes only.

How to Test

Manual/local verification

Agent-executed live local-stack verification passed all six paths through a separate local Kilo process: workspace listing and creation/replay, a second session in the workspace, preparation/sandbox status SSE, native snapshot/fence/live events, and two follow-up messages with durable-history checks. The test used a separate HOME/XDG configuration and left normal CLI authentication unchanged. No human/UI verification was performed.

Only the LLM was deterministic fake. The local Worker, Durable Objects, PostgreSQL, Docker sandbox, wrapper, and Kilo processes were real. Owned workspace and profile records were deleted after the run.

Automated checks executed by the agent:

  • 86 focused CLI tests passed across token exchange, session hosting, local endpoints, real HTTP routing, collisions, and public API schemas.
  • Source typecheck, targeted lint, shared-source annotations, Promise-facade guard, generated source links, and git diff --check passed.
  • Required pre-push checks passed for all 29 TypeScript workspaces and the JetBrains/Kotlin typecheck.

Reviewer test steps

  1. Check out this PR and Cloud integration PR test(cloud-agent): cover the local Kilo facade end to end cloud#8973, which includes the Cloud implementation parent.
  2. Install locked dependencies, select Bun 1.4.4, and start the local Cloud cloud-agent group with an existing funded local account.
  3. From Cloud, run pnpm --filter cloud-agent-next run e2e:local-kilo --kilo-repo /path/to/kilocode --bun /path/to/pinned/bun --email local-user@example.com.
  4. Confirm six passing scenarios and the cleanup report under ignored dev/logs/local-kilo-*.

Blocked checks and substitute verification

No relevant check is blocked. An earlier broader server-directory test run showed unrelated suite-parallelism flakes in permission/model-filter tests; those passed in isolation. The final focused 86-test suite and six-scenario live run passed.

Checklist

  • Issue exception explained above
  • Agent-executed tests and verification described
  • Screenshots/video marked N/A
  • User-facing CLI changeset added
  • Human maintainer review of the AI-assisted implementation

@kilo-code-bot

kilo-code-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1

Incremental review of 6e2b70cf48..ccf4b99895 (8 files, 107 insertions). The previously reported attach-time issues are fixed: attach is now a cheap session read, the catalog walk moved to a new optional Provider.membership that the handler invokes once when minting, renewal no longer touches the catalog, and a vanished workspace detail no longer aborts the scan with not_found. The organization shape for startWorkspaceSession is now backed by a Cloud schema reference and a regression that asserts the top-level field, absence of options, and the organization header.

Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/opencode/test/kilocode/session-hosting/kilo-cloud-workspace.test.ts 797 The new "skips a workspace that disappears" test has a single workspace, so it passes whether the scan continues past the vanished entry or aborts entirely; a second workspace holding the session would pin the intended skip-and-continue behavior.
Files Reviewed (8 files)
  • packages/opencode/src/kilocode/server/httpapi/handlers/session-host.ts - 0 issues
  • packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts - 0 issues
  • packages/opencode/src/kilocode/session-hosting/types.ts - 0 issues
  • packages/opencode/test/kilocode/server/hosted-fixture.ts - 0 issues
  • packages/opencode/test/kilocode/server/hosted-routing.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/httpapi-public.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud-workspace.test.ts - 1 issue
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts - 0 issues

No memory-leak findings: the increment adds no timers or unbounded caches, and the attachment store remains bounded by LIVE_LIMIT/ENDED_LIMIT. Tests were not executed in this read-only review.

Previous Review Summaries (3 snapshots, latest commit 6e2b70c)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 6e2b70c)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 1

Incremental review of 4c4a75d4e6..6e2b70cf48 (5 commits, 24 files, including regenerated SDK/OpenAPI). The increment adds workspace status, delete, session-list, and profile-list endpoints, plus workspace-aware attachment invalidation. The profile lookup uses a new helper (resolveKiloApiOrigin/KILO_API_URL) that mirrors the existing origin resolvers. The previously flagged control-token issues are resolved and the startWorkspaceSession organization-shape question remains open and still unverified. The three new findings are all in the new attach-time workspace membership scan: it is now a hard dependency of attach, repeats a full catalog walk on every 60s attachment expiry, and maps a missing workspace detail to not_found, which callers treat as "session deleted" and use to dispose live attachments. No memory-leak findings: the attachment store remains bounded (LIVE_LIMIT/ENDED_LIMIT) and the increment adds no timers or unbounded caches. Tests were not executed in this read-only review.

Issue Details (click to expand)

WARNING

File Line Issue
packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts 554 attach treats best-effort membership as a hard dependency: a catalog-only failure (listHostedWorkspaces 503, invalid/oversized page) fails attach, so hosted-routing returns 503 for every attachment after its 60s expiry without renewing, even though the session itself was just fetched successfully.
packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts 640 A null getHostedWorkspace for an unrelated catalog workspace aborts the membership scan with not_found. hosted-routing.resolve disposes every attachment for the session on attach not_found, so a concurrently deleted/unreadable workspace turns a live session (notably a legacy flat session that scans every workspace) into disposed attachments returning 410.
packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts 857 Carried: startWorkspaceSession sends kilocodeOrganizationId at the top level while createWorkspace and start nest it under options; still unverified against the createHostedWorkspaceSession input schema (existing open inline thread, no test passes an organization here).

SUGGESTION

File Line Issue
packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts 603 The membership scan runs in full on every attach (each attachment expiry): up to 20 list pages plus up to 10 detail pages per listed workspace, thousands of sequential Worker calls with a control-token exchange each for a large catalog. Reuse the workspace already stored on the attachment or cache the scan per session.
Files Reviewed (24 files)
  • .changeset/hosted-workspace-operations.md - 0 issues
  • packages/kilo-docs/source-links.md - 0 issues
  • packages/opencode/src/kilocode/cloud/origin.ts - 0 issues
  • packages/opencode/src/kilocode/server/hosted-attachments.ts - 0 issues
  • packages/opencode/src/kilocode/server/httpapi/groups/session-host.ts - 0 issues
  • packages/opencode/src/kilocode/server/httpapi/handlers/session-host.ts - 0 issues
  • packages/opencode/src/kilocode/server/httpapi/public.ts - 0 issues
  • packages/opencode/src/kilocode/server/routes/hosted-routing.ts - 0 issues
  • packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts - 4 issues (3 new, 1 carried)
  • packages/opencode/src/kilocode/session-hosting/response.ts - 0 issues
  • packages/opencode/src/kilocode/session-hosting/types.ts - 0 issues
  • packages/opencode/test/kilocode/server/hosted-attachments.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/hosted-fixture.ts - 0 issues
  • packages/opencode/test/kilocode/server/hosted-routing.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts - 0 issues
  • packages/opencode/test/kilocode/server/httpapi-public.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/session-host-httpapi.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud-workspace.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/registry.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/response.test.ts - 0 issues
  • packages/sdk/js/src/v2/gen/sdk.gen.ts - 0 issues (generated)
  • packages/sdk/js/src/v2/gen/types.gen.ts - 0 issues (generated)
  • packages/sdk/openapi.json - 0 issues (generated)

Previous review (commit 4c4a75d)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0

Incremental review of d5637816db..4c4a75d4e6 (2 commits, 6 files). Both previously flagged control-token problems are fixed: issuer 408/429/5xx now map to retryable unavailable, other unexpected statuses to non-retryable invalid_response, and issuer exchange failures are no longer rewritten into the keyless-start "outcome is unknown" error (the ambiguity decision moved into the Worker-dispatch catch; retryable is now operationKey !== undefined). New regressions cover issuer outage on reads and keyless starts, 429 throttling without Worker dispatch, and an issuer transport failure during a keyless start; the existing ambiguity test now uses a working issuer so it actually reaches the Worker transport failure. One earlier finding remains open.

Issue Details (click to expand)

WARNING

File Line Issue
packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts 581 startWorkspaceSession sends kilocodeOrganizationId at the top level, while createWorkspace (554) and start (390) nest it under options. The sibling-symmetry inconsistency is still present and still unverified against the Cloud procedure contract (Cloud repo access unavailable, and no test passes an organization to this method).
Files Reviewed (6 files)
  • packages/opencode/src/kilocode/cloud/control-token.ts - 0 issues (both prior findings fixed)
  • packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts - 1 issue (carried, unchanged line)
  • packages/opencode/test/kilocode/cloud/control-token.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts - 0 issues

Checked against current HEAD 4c4a75d4e6; tests were not executed in this read-only review. The remaining finding is a contract question, not a confirmed defect: it depends on the createHostedWorkspaceSession input schema in the Cloud companion PR.

Fix these issues in Kilo Cloud

Previous review (commit d563781)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 1

The control-token exchange is well isolated (source credential only reaches the issuer, redirects refused, digest-only cache keys, bounded cache) and the new HTTP routes/schemas line up with the hosted-routing path. The findings are about one contract inconsistency in the new workspace-session mutation and two error-classification issues introduced by routing every Worker call through the exchange.

Issue Details (click to expand)

WARNING

File Line Issue
packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts 580 createHostedWorkspaceSession sends kilocodeOrganizationId at the top level, while createWorkspace and start nest it under options; org scoping may be silently dropped, and no test passes an organization to this method.
packages/opencode/src/kilocode/cloud/control-token.ts 152 Every non-5xx issuer status (429, 408, 404, ...) becomes a non-retryable unauthorized, so transient throttle/misconfiguration is reported to IDEs as a 401 credential denial.

SUGGESTION

File Line Issue
packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts 206 An exchange transport failure surfaces as unavailable/retryable before dispatch, but start()'s existing mapError rewrites it to the non-retryable "outcome is unknown" error for keyless starts even though nothing was sent.
Files Reviewed (28 files)
  • .changeset/hosted-workspace-operations.md - 0 issues
  • packages/opencode/script/dev-local.ts - 0 issues
  • packages/opencode/src/kilocode/cloud/control-token.ts - 1 issue
  • packages/opencode/src/kilocode/server/httpapi/groups/cloud-agent.ts - 0 issues
  • packages/opencode/src/kilocode/server/httpapi/groups/session-host.ts - 0 issues
  • packages/opencode/src/kilocode/server/httpapi/handlers/cloud-agent.ts - 0 issues
  • packages/opencode/src/kilocode/server/httpapi/handlers/session-host.ts - 0 issues
  • packages/opencode/src/kilocode/server/httpapi/server.ts - 0 issues
  • packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts - 2 issues
  • packages/opencode/src/kilocode/session-hosting/native-routes.ts - 0 issues
  • packages/opencode/src/kilocode/session-hosting/types.ts - 0 issues
  • packages/opencode/src/server/routes/instance/httpapi/api.ts - 0 issues
  • packages/opencode/test/kilocode/cloud/control-token.test.ts - 0 issues
  • packages/opencode/test/kilocode/script/dev-local.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/hosted-collision.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/hosted-fixture.ts - 0 issues
  • packages/opencode/test/kilocode/server/hosted-routing.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/httpapi-public.test.ts - 0 issues
  • packages/opencode/test/kilocode/server/session-host-httpapi.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud-route.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud-system-feed.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud-workspace.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts - 0 issues
  • packages/opencode/test/kilocode/session-hosting/registry.test.ts - 0 issues
  • packages/sdk/js/src/v2/gen/sdk.gen.ts (generated) - 0 issues
  • packages/sdk/js/src/v2/gen/types.gen.ts (generated) - 0 issues
  • packages/sdk/openapi.json (generated) - 0 issues

Not reviewed against a running Cloud stack: the new createHostedWorkspace*/listHostedWorkspaces tRPC payload shapes were checked against the in-repo sibling calls and tests only, not the Cloud PR contracts.


Reviewed by deepseek-flash-latest · Input: 85.6K · Output: 20.5K · Cached: 1.5M

Review guidance: REVIEW.md from base branch main

Copy link
Copy Markdown
Contributor Author

Maintenance Status

Publication is blocked. The branch is still eshurakov/cloud-agent-hosted-workspaces; the remote PR head remains d5637816db2af38a4cd954fe42e41b1477f22a69. These two commits are local only:

  • c594fc5755 test(cli): exercise hosted workspace HTTP endpoints
  • 4c4a75d4e6 fix(cli): preserve control token failure semantics

The normal push ran the unmodified pre-push hook but stopped at the 210-second command limit before publishing. Bounded workspace checks subsequently completed 28 TypeScript workspaces. The remaining @kilocode/cli#typecheck still receives SIGKILL when run alone with GOMEMLIMIT=2GiB GOGC=10 GOMAXPROCS=2; the sandbox has a 7 GiB memory-group limit and its OOM-kill counter increased during the failed checks. JetBrains was not reached by the hook. No hooks or checks were bypassed, no CI reruns were requested, and no force-push was attempted.

Verified Locally

  • Bun 1.4.4: 86 focused CLI tests pass. New regressions reproduced both error-path defects before the source fix.
  • HTTP exerciser: full static inventory is 348 passing, zero missing/extra/skipped; all five new routes pass focused auth and effect modes with KILO_AUTH_CONTENT removed from the subprocess environment.
  • Targeted lint: zero errors, four warnings on unchanged lines. Formatting, patch whitespace, annotations, and Promise-facade guard pass.
  • No API schema was changed by maintenance, so generated SDK/OpenAPI files were left untouched. No real Cloud E2E was rerun; the previously reported six-scenario run is not new verification from this session.

Feedback And Overlap

All three review threads have evidence-backed replies and remain open. Two defects are fixed only in the unpublished patch; organization payload nesting is unverified because read-only access to Kilo-Org/cloud PR #8972 returns HTTP 502, GitHub authorization unavailable. Sibling payload symmetry is not sufficient reason to change the procedure input.

At the published heads, #15504 (45f5109572af58b9633e4426c5a4b8d46dd7d03e) overlaps this PR in nine changed paths and the same four workspace method/path pairs. These are parallel implementations, not stacked changes. Their profile DTOs, pagination/nullability, provider capabilities, and SDK operation names conflict. In particular, #15504 publicly maps Cloud routing IDs into remoteSessionID and sends the source auth token to the Worker; wholesale integration would regress this PR security boundaries. Its unique status/deletion/profile-discovery features can be a reduced follow-up on the reconciled core API. Preserve this PR source-to-control exchange, private routing IDs, opaque attachments, and real long-lived system-feed registration; regenerate SDK artifacts only after reconciling the contracts. Neither #15504 nor either companion Cloud branch was modified.

Current required checks at the unchanged head: test (linux) and unit (linux) are red; typecheck and both visual-regression checks are green. The original run contains the five missing HTTP scenarios and three Windows Bun-download bootstrap timeouts before tests. This is not a post-push CI result. Human review is still required.

The filesystem is ephemeral, so the exact unpublished commits are preserved below for recovery without weakening any checks.

Unpublished maintenance commits as mailbox patches
From c594fc575568976b1561766c977efdcc137f268f Mon Sep 17 00:00:00 2001
From: eshurakov <54751+eshurakov@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:13:02 +0000
Subject: [PATCH 1/2] test(cli): exercise hosted workspace HTTP endpoints

---
 .../server/httpapi-exercise-scenarios.ts      | 54 +++++++++++++++++++
 1 file changed, 54 insertions(+)

diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
index 96f5f8640f..23a56e0ee9 100644
--- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
+++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
@@ -469,6 +469,14 @@ export const kiloScenarios: Scenario[] = [
     .post("/kilo/cloud/session/import", "kilo.cloud.session.import")
     .at((ctx) => ({ path: "/kilo/cloud/session/import", headers: ctx.headers(), body: { sessionId: "missing" } }))
     .status(401),
+  http.protected
+    .get("/cloud-agent/event", "cloudAgent.systemEvents")
+    .at((ctx) => ({ path: "/cloud-agent/event", headers: ctx.headers() }))
+    .status(400, (_ctx, result) =>
+      Effect.sync(() => {
+        check(result.text.includes("hosted attachment"), "system events without a hosted attachment should be rejected")
+      }),
+    ),
   http.protected.get("/kilocode/session-host/provider", "sessionHost.providers").json(200, (body) => {
     object(body)
     array(body.providers)
@@ -531,6 +539,52 @@ export const kiloScenarios: Scenario[] = [
       headers: ctx.headers(),
     }))
     .status(404),
+  http.protected
+    .get("/kilocode/session-host/workspace", "sessionHost.listWorkspaces")
+    .probe({ path: "/kilocode/session-host/workspace?limit=0" })
+    .at((ctx) => ({ path: "/kilocode/session-host/workspace", headers: ctx.headers() }))
+    .status(401),
+  http.protected
+    .post("/kilocode/session-host/workspace", "sessionHost.createWorkspace")
+    .at((ctx) => ({
+      path: "/kilocode/session-host/workspace",
+      headers: ctx.headers(),
+      body: {
+        repository: { type: "github", repo: "owner/repo" },
+        profile: { id: "33333333-3333-4333-8333-333333333333" },
+        prompt: "hello",
+        agent: { mode: "build", model: "test/fake" },
+        operationKey: "11111111-1111-4111-8111-111111111111",
+        messageID: "msg_00000000000000000000000002",
+      },
+    }))
+    .status(401),
+  http.protected
+    .get("/kilocode/session-host/workspace/{providerID}/{workspaceID}", "sessionHost.getWorkspace")
+    .at((ctx) => ({
+      path: route("/kilocode/session-host/workspace/{providerID}/{workspaceID}", {
+        providerID: "kilo-cloud",
+        workspaceID: "worktree_00000000-0000-4000-8000-000000000001",
+      }),
+      headers: ctx.headers(),
+    }))
+    .status(401),
+  http.protected
+    .post("/kilocode/session-host/workspace/{providerID}/{workspaceID}/session", "sessionHost.startWorkspaceSession")
+    .at((ctx) => ({
+      path: route("/kilocode/session-host/workspace/{providerID}/{workspaceID}/session", {
+        providerID: "kilo-cloud",
+        workspaceID: "worktree_00000000-0000-4000-8000-000000000001",
+      }),
+      headers: ctx.headers(),
+      body: {
+        prompt: "hello",
+        agent: { mode: "build", model: "test/fake" },
+        operationKey: "22222222-2222-4222-8222-222222222222",
+        messageID: "msg_00000000000000000000000003",
+      },
+    }))
+    .status(401),
   http.protected.get("/network", "network.list").json(200, array),
   http.protected
     .post("/network/{requestID}/reply", "network.reply")
-- 
2.47.3


From 4c4a75d4e6f84d013b47e7baa388a008816eb9b3 Mon Sep 17 00:00:00 2001
From: eshurakov <54751+eshurakov@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:13:08 +0000
Subject: [PATCH 2/2] fix(cli): preserve control token failure semantics

---
 .../src/kilocode/cloud/control-token.ts       | 10 +++-
 .../kilocode/session-hosting/kilo-cloud.ts    | 42 +++++++-------
 .../test/kilocode/cloud/control-token.test.ts |  4 ++
 .../kilo-cloud-auth-flow.test.ts              | 55 ++++++++++++++++++-
 .../session-hosting/kilo-cloud.test.ts        |  2 +-
 5 files changed, 88 insertions(+), 25 deletions(-)

diff --git a/packages/opencode/src/kilocode/cloud/control-token.ts b/packages/opencode/src/kilocode/cloud/control-token.ts
index ed083d8e6b..d87eaa2a19 100644
--- a/packages/opencode/src/kilocode/cloud/control-token.ts
+++ b/packages/opencode/src/kilocode/cloud/control-token.ts
@@ -146,10 +146,16 @@ async function issue(
 }
 
 function errorForStatus(status: number) {
-  if (status === 503 || status >= 500) {
+  if (status === 401 || status === 403) {
+    return new ControlTokenError("unauthorized", "Kilo Cloud denied the control token request")
+  }
+  if (status === 408 || status === 429 || status >= 500) {
     return new ControlTokenError("unavailable", "Kilo Cloud token issuance is temporarily unavailable")
   }
-  return new ControlTokenError("unauthorized", "Kilo Cloud denied the control token request")
+  return new ControlTokenError(
+    "invalid_response",
+    `Kilo control token issuer returned an unexpected HTTP status: ${status}`,
+  )
 }
 
 function cacheKey(request: ControlTokenRequest) {
diff --git a/packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts b/packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts
index 43e15c8f7a..40605ab2e3 100644
--- a/packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts
+++ b/packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts
@@ -191,7 +191,7 @@ export function provider(options: Options): Provider {
    * short-lived control token first. The source credential is presented only to
    * the issuer; the Worker sees only the control token.
    */
-  const request = (auth: CloudAuth.Resolved, req: BearerRequest) =>
+  const request = (auth: CloudAuth.Resolved, req: BearerRequest, retryable = true) =>
     Effect.gen(function* () {
       const base = yield* origin
       const issuer = yield* webOrigin
@@ -214,7 +214,13 @@ export function provider(options: Options): Provider {
           }).request(req),
         catch: () => {
           unreachable()
-          return new HostedFailure({ code: "unavailable", message: "Kilo Cloud is unreachable", retryable: true })
+          return new HostedFailure({
+            code: "unavailable",
+            message: retryable
+              ? "Kilo Cloud is unreachable"
+              : "Kilo Cloud start outcome is unknown; retry with the same operation key and message ID",
+            retryable,
+          })
         },
       })
       reachable()
@@ -391,20 +397,15 @@ export function provider(options: Options): Provider {
           new HostedFailure({ code: "invalid_request", message: "Session start request is invalid", retryable: false }),
         )
       }
-      const response = yield* request(auth, {
-        method: "POST",
-        path: "/trpc/start",
-        headers: { ...headers(auth), "content-type": "application/json" },
-        body: JSON.stringify(parsed.data),
-      }).pipe(
-        Effect.mapError((error) => {
-          if (error.code !== "unavailable" || !error.retryable || input.operationKey !== undefined) return error
-          return new HostedFailure({
-            code: "unavailable",
-            message: "Kilo Cloud start outcome is unknown; retry with the same operation key and message ID",
-            retryable: false,
-          })
-        }),
+      const response = yield* request(
+        auth,
+        {
+          method: "POST",
+          path: "/trpc/start",
+          headers: { ...headers(auth), "content-type": "application/json" },
+          body: JSON.stringify(parsed.data),
+        },
+        input.operationKey !== undefined,
       )
       const body = yield* consume(response)
       const envelope = Envelope.safeParse(body)
@@ -623,7 +624,10 @@ export function provider(options: Options): Provider {
       }
       const auth = yield* resolve(input.organizationID)
       const headers: Record<string, string> = {
-        ...buildKiloHeaders(undefined, auth.organizationID ? { kilocodeOrganizationId: auth.organizationID } : undefined),
+        ...buildKiloHeaders(
+          undefined,
+          auth.organizationID ? { kilocodeOrganizationId: auth.organizationID } : undefined,
+        ),
       }
       for (const [key, value] of input.headers.entries()) headers[key] = value
       const query = mapped.query.toString()
@@ -631,9 +635,7 @@ export function provider(options: Options): Provider {
         method: mapped.method,
         path: query.length === 0 ? mapped.remote : `${mapped.remote}?${query}`,
         headers,
-        ...(input.body && input.body.length > 0
-          ? { body: new Blob([new Uint8Array(input.body).buffer]) }
-          : {}),
+        ...(input.body && input.body.length > 0 ? { body: new Blob([new Uint8Array(input.body).buffer]) } : {}),
         // Both native event streams are long-lived; every other call keeps the default bound.
         ...(mapped.remote === "/kilo/event" || mapped.remote === "/kilo/cloud-agent/event" ? { timeoutMs: 0 } : {}),
       })
diff --git a/packages/opencode/test/kilocode/cloud/control-token.test.ts b/packages/opencode/test/kilocode/cloud/control-token.test.ts
index 0fa001004b..e19cb25ba9 100644
--- a/packages/opencode/test/kilocode/cloud/control-token.test.ts
+++ b/packages/opencode/test/kilocode/cloud/control-token.test.ts
@@ -124,8 +124,12 @@ test("normalizes issuer failures without retrying", async () => {
   for (const item of [
     { status: 401, reason: "unauthorized" },
     { status: 403, reason: "unauthorized" },
+    { status: 408, reason: "unavailable" },
+    { status: 429, reason: "unavailable" },
     { status: 503, reason: "unavailable" },
     { status: 500, reason: "unavailable" },
+    { status: 400, reason: "invalid_response" },
+    { status: 404, reason: "invalid_response" },
   ] as const) {
     const app = issuer(() => Response.json({ error: "denied" }, { status: item.status }))
     try {
diff --git a/packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts b/packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts
index 9b7bddd599..dc67ab83b8 100644
--- a/packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts
+++ b/packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts
@@ -34,7 +34,7 @@ function harness() {
   const worker: Recorded[] = []
   let now = 1_000_000
   const state = {
-    issue: "ok" as "ok" | "deny" | "unavailable",
+    issue: "ok" as "ok" | "deny" | "unavailable" | "throttled",
     token: CONTROL,
   }
   const server = Bun.serve({
@@ -54,6 +54,7 @@ function harness() {
         issuer.push(recorded)
         if (state.issue === "deny") return Response.json({ error: "denied" }, { status: 401 })
         if (state.issue === "unavailable") return Response.json({ error: "disabled" }, { status: 503 })
+        if (state.issue === "throttled") return Response.json({ error: "limited" }, { status: 429 })
         return Response.json({ token: state.token, expiresAt: new Date(now + 600_000).toISOString() })
       }
       worker.push(recorded)
@@ -161,7 +162,7 @@ it.effect("reports a denied exchange and never dispatches the Worker", () => {
   }).pipe(Effect.ensuring(Effect.sync(() => app.stop())))
 })
 
-it.effect("reports an unavailable issuer without dispatching the Worker", () => {
+it.effect("keeps issuer outages retryable before reads and keyless starts", () => {
   const app = harness()
   const store = authStore()
   const cloud = client(app, store)
@@ -169,7 +170,57 @@ it.effect("reports an unavailable issuer without dispatching the Worker", () =>
   return Effect.gen(function* () {
     const error = yield* cloud.list({}).pipe(Effect.flip)
     expect(error).toMatchObject({ code: "unavailable", retryable: true })
+    const start = yield* cloud
+      .start({
+        repository: { type: "github", repo: "Kilo-Org/kilocode" },
+        prompt: "hello",
+        agent: { mode: "build", model: "test/fake" },
+      })
+      .pipe(Effect.flip)
+    expect(start).toMatchObject({ code: "unavailable", retryable: true })
+    expect(app.issuer).toHaveLength(2)
+    expect(app.worker).toHaveLength(0)
+    expect(store.writes).toEqual({ set: 0, remove: 0 })
+  }).pipe(Effect.ensuring(Effect.sync(() => app.stop())))
+})
+
+it.effect("reports issuer throttling as retryable without dispatching the Worker", () => {
+  const app = harness()
+  const store = authStore()
+  const cloud = client(app, store)
+  app.state.issue = "throttled"
+  return Effect.gen(function* () {
+    const error = yield* cloud.list({}).pipe(Effect.flip)
+    expect(error).toMatchObject({ code: "unavailable", retryable: true })
+    expect(app.issuer).toHaveLength(1)
     expect(app.worker).toHaveLength(0)
+    expect(store.writes).toEqual({ set: 0, remove: 0 })
+  }).pipe(Effect.ensuring(Effect.sync(() => app.stop())))
+})
+
+it.effect("keeps a keyless start retryable when the issuer transport fails", () => {
+  const app = harness()
+  const store = authStore()
+  const cloud = kiloCloud({
+    auth: store.interface,
+    env: { CLOUD_AGENT_NEXT_BASE_URL: app.url, KILO_WEB_APP_URL: "http://127.0.0.1:1" },
+  })
+  return Effect.gen(function* () {
+    const error = yield* cloud
+      .start({
+        repository: { type: "github", repo: "Kilo-Org/kilocode" },
+        prompt: "hello",
+        agent: { mode: "build", model: "test/fake" },
+      })
+      .pipe(Effect.flip)
+    expect(error).toMatchObject({
+      code: "unavailable",
+      message: "Kilo control token could not be issued",
+      retryable: true,
+    })
+    expect(app.issuer).toHaveLength(0)
+    expect(app.worker).toHaveLength(0)
+    expect(store.writes).toEqual({ set: 0, remove: 0 })
   }).pipe(Effect.ensuring(Effect.sync(() => app.stop())))
 })
 
diff --git a/packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts b/packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts
index fccac9f416..3f34fdd0f1 100644
--- a/packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts
+++ b/packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts
@@ -338,7 +338,7 @@ it.effect("marks an ambiguous start as non-retryable without an idempotency key"
   const app = harness()
   const cloud = kiloCloud({
     auth: auth(signedIn()),
-    env: { CLOUD_AGENT_NEXT_BASE_URL: "http://127.0.0.1:1", KILO_WEB_APP_URL: "http://127.0.0.1:1" },
+    env: { CLOUD_AGENT_NEXT_BASE_URL: "http://127.0.0.1:1", KILO_WEB_APP_URL: app.url },
   })
   return Effect.gen(function* () {
     const failure = yield* cloud
-- 
2.47.3

const cloud = client(app.url)
app.state.membership = "nested"
app.state.detail = "null"
expect(yield* membership(cloud)).toBeUndefined()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: This test cannot tell skipping apart from aborting the scan

The catalog here has a single workspace, so membership returns undefined whether nested skips the vanished workspace and keeps scanning or accidentally ends the whole scan. The behavior promised by the comment at kilo-cloud.ts:635 is a skip: a later workspace that holds the session must still be found. Consider serving two workspaces where the first detail is null and the second holds the session, then asserting membership resolves to the second workspace.

@kirillk

kirillk commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Recovered the failed post-merge v2-port tracker in #15523 (core-v1-changes-port). The tracker failure was issue-draft placeholder validation, not an implementation check failure.

@github-actions github-actions Bot added the area:docs Documentation label Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli CLI, TUI and core engine area:docs Documentation area:sdk SDK

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants