Merge remote-tracking branch 'origin/main' into orchestratio-issue

This commit is contained in:
Jinwoo-H
2026-09-07 03:54:39 -04:00
105 changed files with 2482 additions and 838 deletions
+364
View File
@@ -0,0 +1,364 @@
name: Deploy Push Gateway Production
on:
workflow_dispatch:
inputs:
source_sha:
description: Full reviewed commit SHA to build (feature may remain unmerged)
required: true
type: string
confirmation:
description: Enter DEPLOY_PUSH_GATEWAY to shift production traffic
required: true
type: string
permissions:
contents: read
id-token: write
# The gateway applies its own schema at startup against the shared Cloud SQL instance, so a
# deploy is a connection-budget rollout and belongs in the same serialized group as the relay.
concurrency:
group: production-cloud-sql-rollout
cancel-in-progress: false
defaults:
run:
working-directory: cloud
jobs:
deploy:
if: >-
${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' &&
github.ref == 'refs/heads/main' }}
runs-on: blacksmith-2vcpu-ubuntu-2204
environment: production
env:
GCP_PROJECT_ID: onorca-cloud
GCP_REGION: ${{ vars.PRODUCTION_GCP_REGION }}
SERVICE_NAME: orca-cloud-push
REPOSITORY_ID: orca-cloud
IMAGE_NAME: push
PUSH_ORIGIN: https://push.onorca.dev
PUSH_RUNTIME_SERVICE_ACCOUNT: orca-cloud-push@onorca-cloud.iam.gserviceaccount.com
# Scaling the serving revision must already hold, matching push_min_instances and
# push_max_instances. Terraform owns both, and the candidate inherits them from the
# service, so this deploy never passes a scaling flag: doing so would write a
# Terraform-owned field that `lifecycle.ignore_changes` does not cover, and a later
# `push_max_instances` raise would then be reverted by every deploy. These two values
# are the expected shape, asserted before the candidate is created and again on the
# candidate itself, so a deploy that would change the gateway's Cloud SQL draw fails.
PUSH_MIN_INSTANCES: 1
PUSH_MAX_INSTANCES: 2
CONFIRMATION: ${{ inputs.confirmation }}
SOURCE_SHA: ${{ inputs.source_sha }}
steps:
- uses: actions/checkout@v4
- name: Require the explicit deploy confirmation
shell: bash
run: |
set -euo pipefail
test "${CONFIRMATION}" = DEPLOY_PUSH_GATEWAY
[[ "${SOURCE_SHA}" =~ ^[a-f0-9]{40}$ ]]
# Keep the workflow and rollout lease on main; only the Docker build uses candidate code.
- name: Fetch the immutable gateway source
shell: bash
run: |
set -euo pipefail
git fetch --no-tags origin "${SOURCE_SHA}"
test "$(git rev-parse FETCH_HEAD)" = "${SOURCE_SHA}"
mkdir -p "${RUNNER_TEMP}/push-source"
git -C "${GITHUB_WORKSPACE}" archive "${SOURCE_SHA}" cloud \
| tar -x -C "${RUNNER_TEMP}/push-source"
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- uses: docker/setup-buildx-action@v3
- name: Configure Docker auth
run: gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet
# Why: the build runs before the lease. Artifact Registry is not the Cloud SQL instance,
# and a multi-minute image build inside the lease blocks every relay deploy and rehome for
# its duration. The lease below covers exactly the connection-budget window: deploy, probe,
# shift.
- name: Build and publish the immutable gateway image
shell: bash
run: |
set -euo pipefail
image_tag="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}:sha-${SOURCE_SHA}"
docker build -f "${RUNNER_TEMP}/push-source/cloud/apps/push/Dockerfile" \
-t "${image_tag}" "${RUNNER_TEMP}/push-source/cloud"
docker push "${image_tag}"
digest="$(gcloud artifacts docker images describe "${image_tag}" \
--format='value(image_summary.digest)')"
[[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]
echo "IMAGE=${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}@${digest}" \
>> "${GITHUB_ENV}"
echo "IMAGE_DIGEST=${digest}" >> "${GITHUB_ENV}"
# Held across the deploy, not just a separate schema step: the gateway opens its pool and
# applies its schema while the new revision starts, so the revision is the schema step.
- uses: ./.github/actions/cloud-sql-rollout-lease
with:
bucket: onorca-cloud-terraform-state
object: terraform/state/cloud-sql-rollout/production.lock
# Why: the candidate inherits the serving revision's scaling. A serving revision that has
# drifted below the floor would hand the candidate a cold start on every notification, and
# one that has drifted above the ceiling would hand it a larger Cloud SQL draw than the
# rollout lease was taken for. Refuse to inherit either rather than latch it.
- name: Record the serving revision and require its Terraform-owned scaling
shell: bash
run: |
set -euo pipefail
serving="$(gcloud run services describe "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
| jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
| if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
test -n "${serving}"
floor="$(gcloud run revisions describe "${serving}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
--format="value(metadata.annotations['autoscaling.knative.dev/minScale'])")"
if [[ "${floor:-0}" -lt "${PUSH_MIN_INSTANCES}" ]]; then
echo "serving revision ${serving} holds ${floor:-0} minimum instances," \
"below ${PUSH_MIN_INSTANCES}; deploying would inherit and latch it." >&2
echo "Restore the floor first: gcloud run services update ${SERVICE_NAME}" \
"--region ${GCP_REGION} --min-instances=${PUSH_MIN_INSTANCES}" >&2
exit 1
fi
ceiling="$(gcloud run revisions describe "${serving}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
--format="value(metadata.annotations['autoscaling.knative.dev/maxScale'])")"
test "${ceiling}" = "${PUSH_MAX_INSTANCES}"
echo "serving revision ${serving} holds ${floor} minimum and ${ceiling} maximum instances"
echo "ROLLBACK_REVISION=${serving}" >> "${GITHUB_ENV}"
# No traffic and a per-revision tag: the candidate boots, applies schema, and is probed on
# its own URL while every phone and desktop still reaches the previous revision.
- name: Deploy the candidate revision with no traffic
shell: bash
run: |
set -euo pipefail
tag="c${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "CANDIDATE_TAG=${tag}" >> "${GITHUB_ENV}"
echo "CANDIDATE_REVISION=${SERVICE_NAME}-${tag}" >> "${GITHUB_ENV}"
gcloud run deploy "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--image "${IMAGE}" \
--tag "${tag}" \
--revision-suffix "${tag}" \
--no-traffic \
--quiet
candidate="$(gcloud run services describe "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
| jq -er --arg tag "${tag}" \
'[.status.traffic[] | select(.tag == $tag)]
| if length == 1 then .[0] else error("tagged candidate is not unique") end')"
test "$(jq -r '.revisionName' <<< "${candidate}")" = "${SERVICE_NAME}-${tag}"
echo "CANDIDATE_URL=$(jq -r '.url' <<< "${candidate}")" >> "${GITHUB_ENV}"
# A tagged revision is directly addressable and sits outside the service-wide cap, so the
# candidate and the serving revision each draw up to the ceiling during the probe window.
# The lease is taken for exactly that doubling; a candidate that inherited a wider ceiling
# would exceed it, so the inherited scaling is asserted here too.
- name: Require the candidate to serve the exact image and inherited scaling
shell: bash
run: |
set -euo pipefail
served="$(gcloud run revisions describe "${CANDIDATE_REVISION}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
--format='value(spec.containers[0].image)')"
test "${served}" = "${IMAGE}"
test "${CANDIDATE_REVISION}" != "${ROLLBACK_REVISION}"
candidate_ceiling="$(gcloud run revisions describe "${CANDIDATE_REVISION}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \
--format="value(metadata.annotations['autoscaling.knative.dev/maxScale'])")"
test "${candidate_ceiling}" = "${PUSH_MAX_INSTANCES}"
- name: Probe the candidate readiness endpoint
shell: bash
run: |
set -euo pipefail
[[ "${CANDIDATE_URL}" =~ ^https://[^/]+$ ]]
for attempt in $(seq 1 30); do
code="$(curl -sS -o "${RUNNER_TEMP}/push-ready.json" -w '%{http_code}' \
--max-time 10 "${CANDIDATE_URL}/ready" || true)"
if test "${code}" = 200; then
jq -e . < "${RUNNER_TEMP}/push-ready.json" > /dev/null
curl --fail --silent --show-error --max-time 10 "${CANDIDATE_URL}/health" \
| jq -e '.ok == true and .deliveryProtocol == 2' > /dev/null
echo "candidate ${CANDIDATE_REVISION} is ready after ${attempt} attempt(s)"
exit 0
fi
echo "attempt ${attempt}: /ready returned ${code}"
sleep 5
done
echo "candidate ${CANDIDATE_REVISION} never reported ready" >&2
exit 1
# Why: a gateway that boots and answers /ready can still be unable to send. This proves the
# runtime account's FCM grant end to end without delivering anything: validate_only stops
# Google before any push, and the deliberately invalid token means a healthy credential
# answers INVALID_ARGUMENT. PERMISSION_DENIED is the failure this step exists to catch.
#
# Only the four verdicts below are conclusive. A 429, a 5xx, or a transport failure says
# nothing about the credential, so it is retried rather than treated as either answer; a
# denied credential still fails on the first attempt, without burning the retries.
- name: Prove the runtime identity can reach FCM
shell: bash
run: |
set -euo pipefail
token="$(gcloud auth print-access-token \
--impersonate-service-account "${PUSH_RUNTIME_SERVICE_ACCOUNT}")"
test -n "${token}"
echo "::add-mask::${token}"
body='{"validate_only":true,"message":{"token":"orca-push-deploy-probe-invalid-token","notification":{"title":"Orca","body":"deploy probe"}}}'
for attempt in $(seq 1 5); do
code="$(curl -sS -o "${RUNNER_TEMP}/push-fcm.json" -w '%{http_code}' --max-time 20 \
-X POST "https://fcm.googleapis.com/v1/projects/${GCP_PROJECT_ID}/messages:send" \
-H "Authorization: Bearer ${token}" \
-H 'Content-Type: application/json' \
--data "${body}" || true)"
status="$(jq -r '.error.status // empty' < "${RUNNER_TEMP}/push-fcm.json" || true)"
echo "attempt ${attempt}: FCM validate-only send returned HTTP ${code} status ${status:-OK}"
if test "${status}" = PERMISSION_DENIED || test "${status}" = INVALID_ARGUMENT ||
test "${code}" = 401 || test "${code}" = 403; then
break
fi
sleep 5
done
if test "${status}" = PERMISSION_DENIED || test "${code}" = 401 || test "${code}" = 403; then
echo "the push runtime identity cannot send through FCM" >&2
exit 1
fi
test "${status}" = INVALID_ARGUMENT
- name: Shift all traffic to the verified candidate
shell: bash
run: |
set -euo pipefail
echo "TRAFFIC_SHIFT_ATTEMPTED=true" >> "${GITHUB_ENV}"
gcloud run services update-traffic "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--to-revisions "${CANDIDATE_REVISION}=100" \
--quiet
serving="$(gcloud run services describe "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
| jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
| if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
test "${serving}" = "${CANDIDATE_REVISION}"
echo "TRAFFIC_SHIFTED=true" >> "${GITHUB_ENV}"
# Why: the summary is written before the origin check, not after it. Once traffic has
# moved, the rollback target is the single thing an operator needs, and a summary that only
# appeared on success would be missing in exactly the run that needs it.
- name: Publish the rollout summary
if: ${{ always() && env.CANDIDATE_REVISION != '' && env.ROLLBACK_REVISION != '' }}
shell: bash
run: |
set -euo pipefail
{
echo '### Push gateway rollout'
echo
echo "Source: ${SOURCE_SHA}"
echo
echo "Revision: \`${CANDIDATE_REVISION}\`"
echo
echo "Image: \`${IMAGE_DIGEST}\`"
echo
echo "Rollback: \`gcloud run services update-traffic ${SERVICE_NAME}" \
"--region ${GCP_REGION} --to-revisions ${ROLLBACK_REVISION}=100\`"
} >> "${GITHUB_STEP_SUMMARY}"
- name: Verify the public origin after the shift
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \
"${PUSH_ORIGIN}/ready" || true)"
if test "${code}" = 200; then
curl --fail --silent --show-error --max-time 10 "${PUSH_ORIGIN}/health" \
| jq -e '.ok == true and .deliveryProtocol == 2' > /dev/null
echo "${PUSH_ORIGIN} is ready after ${attempt} attempt(s)"
exit 0
fi
echo "attempt ${attempt}: ${PUSH_ORIGIN}/ready returned ${code}"
sleep 5
done
echo "${PUSH_ORIGIN} never reported ready after the shift" >&2
exit 1
# Why: everything after the shift runs with production on the candidate. A failure there
# is not a failure to deploy, it is a live gateway that has to go back, so the traffic move
# is undone here rather than left to whoever reads the run.
- name: Roll traffic back to the previous revision
if: ${{ (failure() || cancelled()) && env.TRAFFIC_SHIFT_ATTEMPTED == 'true' }}
shell: bash
run: |
set -euo pipefail
test -n "${ROLLBACK_REVISION:-}"
gcloud run services update-traffic "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--to-revisions "${ROLLBACK_REVISION}=100" \
--quiet
serving="$(gcloud run services describe "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \
| jq -r '[.status.traffic[] | select((.percent // 0) > 0)]
| if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')"
test "${serving}" = "${ROLLBACK_REVISION}"
echo "TRAFFIC_ROLLED_BACK=true" >> "${GITHUB_ENV}"
{
echo
echo '### Push gateway rolled back'
echo
echo "Traffic returned to \`${ROLLBACK_REVISION}\`; the candidate" \
"\`${CANDIDATE_REVISION}\` no longer serves."
} >> "${GITHUB_STEP_SUMMARY}"
# Why: a candidate that never took traffic is a revision holding a warm floor and a Cloud
# SQL pool for nothing. Its tag comes off first, because Cloud Run refuses to delete a
# revision a traffic target still names, and clearing CANDIDATE_TAG makes the always() tag
# step below a no-op rather than a second failure.
- name: Delete the rejected candidate revision
if: ${{ (failure() || cancelled()) && (env.TRAFFIC_SHIFT_ATTEMPTED != 'true' || env.TRAFFIC_ROLLED_BACK == 'true') }}
shell: bash
run: |
set -euo pipefail
test -n "${CANDIDATE_REVISION:-}" || exit 0
if test -n "${CANDIDATE_TAG:-}"; then
gcloud run services update-traffic "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--remove-tags "${CANDIDATE_TAG}" \
--quiet
echo "CANDIDATE_TAG=" >> "${GITHUB_ENV}"
fi
gcloud run revisions delete "${CANDIDATE_REVISION}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--quiet
echo "deleted the candidate revision ${CANDIDATE_REVISION}"
- name: Drop the candidate traffic tag
if: always()
shell: bash
run: |
set -euo pipefail
test -n "${CANDIDATE_TAG:-}" || exit 0
gcloud run services update-traffic "${SERVICE_NAME}" \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
--remove-tags "${CANDIDATE_TAG}" \
--quiet
+1
View File
@@ -856,6 +856,7 @@ jobs:
src/main/agent-hooks/windows-hook-payload-delivery.test.ts
src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts
src/main/windows/windows-pty-job.win32.test.ts
src/main/windows/windows-msys-job.win32.test.ts
src/main/windows/windows-host-job.win32.test.ts
src/main/windows/windows-process-tree-command-line-patch.test.ts
src/main/windows/windows-process-table-native-addon.win32.test.ts
@@ -283,6 +283,8 @@ export const LEASED_WORKFLOWS = named([
'operate-relay-production-rehome.yml',
production({ leaseFiles: ['operate-relay-production-rehome-job.yml'] })
],
// The gateway applies its schema at startup, so its deploy revision is the schema step.
['push-deploy.yml', production()],
['deploy-relay-asia-topology.yml', eitherEnvironment()],
['operate-relay-asia-admission.yml', eitherEnvironment()],
['deploy-relay-staging.yml', staging()],
@@ -32,7 +32,8 @@ test('no workflow names the retired generic production deploy identity', async (
'deploy-relay-production.yml',
'operate-relay-asia-admission.yml',
'operate-relay-production-rehome-job.yml',
'publish-relay-production.yml'
'publish-relay-production.yml',
'push-deploy.yml'
].map((name) => relayWorkflowFile(name)).sort())
})
@@ -20,7 +20,7 @@ const UNGATED = relayWorkflowFile('verify.yml')
const relayWorkflows = () => workflowFiles().filter((file) => file !== UNGATED)
test('the copy carries every relay workflow', () => {
assert.equal(relayWorkflows().length, 24)
assert.equal(relayWorkflows().length, 25)
})
// Why: workflow_run chains match by display name, not filename. Renaming a file is safe; renaming
+69 -38
View File
@@ -603,7 +603,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..2ae787c5bd4f3eba470584dc658a01a5
}
#endif
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c97209248e 100644
index 7b286d3d644c26141df516929703aa6e129df4b2..4b06d18576c807c3d1181a7bd714140c6678cf86 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -18,6 +18,7 @@
@@ -614,7 +614,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
#include <vector>
#include <Windows.h>
#include <strsafe.h>
@@ -44,12 +45,39 @@ struct pty_baton {
@@ -44,12 +45,40 @@ struct pty_baton {
HANDLE hOut;
HPCON hpc;
@@ -630,6 +630,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
+ // refused to create or assign one (an outer job without breakaway rights),
+ // in which case callers fall back to their pre-job behaviour.
+ HANDLE hJob = nullptr;
+ bool allowJobBreakaway = true;
+
+ // Orca: teardown needs BOTH the shell's death and an explicit kill() before
+ // the baton can be freed, so each side records that it has run. Whichever
@@ -655,7 +656,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
static volatile LONG ptyCounter;
static pty_baton* get_pty_baton(int id) {
@@ -102,8 +130,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
@@ -102,8 +131,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
// Get process exit code.
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
// Clean up handles
@@ -689,7 +690,36 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
@@ -409,6 +460,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -242,6 +294,20 @@
return HRESULT_FROM_WIN32(GetLastError());
}
+// Cygwin and MSYS request breakaway for every child whenever the job allows it,
+// so their shells need one that does not. The runtime DLL on the exe's search
+// path is the signal; Git for Windows ships bash.exe in bin\ beside usr\bin\.
+static bool usesCygwinRuntime(const std::wstring& shellpath) {
+ const size_t separator = shellpath.find_last_of(L"\\/");
+ if (separator == std::wstring::npos) return false;
+ const std::wstring directory = shellpath.substr(0, separator + 1);
+ for (const wchar_t* dll : {L"msys-2.0.dll", L"cygwin1.dll"}) {
+ if (path_util::file_exists(directory + dll) ||
+ path_util::file_exists(directory + L"..\\usr\\bin\\" + dll)) return true;
+ }
+ return false;
+}
+
static Napi::Value PtyStartProcess(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
@@ -303,6 +369,7 @@
marshal.Set("pty", Napi::Number::New(env, ptyId));
ptyHandles.emplace_back(
std::make_unique<pty_baton>(ptyId, hIn, hOut, hpc));
+ ptyHandles.back()->allowJobBreakaway = !usesCygwinRuntime(shellpath);
} else {
throw Napi::Error::New(env, "Cannot launch conpty");
}
@@ -409,6 +476,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "UpdateProcThreadAttribute failed");
}
@@ -705,7 +735,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
PROCESS_INFORMATION piClient{};
fSuccess = !!CreateProcessW(
nullptr,
@@ -416,7 +476,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -416,7 +492,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
nullptr, // lpProcessAttributes
nullptr, // lpThreadAttributes
false, // bInheritHandles VERY IMPORTANT that this is false
@@ -717,7 +747,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
envArg, // lpEnvironment
mutableCwd.get(), // lpCurrentDirectory
&siEx.StartupInfo, // lpStartupInfo
@@ -426,8 +489,47 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -426,8 +505,48 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "Cannot create process");
}
@@ -735,13 +765,14 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
+ // EXPLICIT teardown exact, not to redefine what a clean exit means.
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
+ if (hJob != nullptr) {
+ // Why BREAKAWAY_OK and not a bare job: with no limits set, a child asking
+ // for CREATE_BREAKAWAY_FROM_JOB is refused with ERROR_ACCESS_DENIED.
+ // Installers, msiexec and some updater and service-control paths spawn that
+ // way deliberately, so a bare job breaks them ONLY inside an Orca terminal.
+ // With this flag a child has to ask, so ordinary descendants stay owned.
+ // Native shells retain explicit breakaway for installers and updaters.
+ // Cygwin/MSYS shells take it automatically for ordinary children whenever
+ // this flag is present, so they get strict per-PTY membership instead.
+ // Explicit breakaway requests inside such a pane are consequently denied;
+ // ordinary backgrounding and clean shell exit remain supported.
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits{};
+ jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_BREAKAWAY_OK;
+ jobLimits.BasicLimitInformation.LimitFlags =
+ handle->allowJobBreakaway ? JOB_OBJECT_LIMIT_BREAKAWAY_OK : 0;
+ if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobLimits, sizeof(jobLimits)) ||
+ !AssignProcessToJobObject(hJob, piClient.hProcess)) {
+ // Why tolerate failure: an outer job without JOB_OBJECT_LIMIT_BREAKAWAY_OK
@@ -767,7 +798,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
if (useConptyDll && fLoadedDll)
{
PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress(
@@ -440,6 +542,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -440,6 +559,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
// Update handle
handle->hShell = piClient.hProcess;
@@ -776,11 +807,16 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
// Close the thread handle to avoid resource leak
CloseHandle(piClient.hThread);
@@ -544,29 +648,215 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
@@ -544,27 +665,213 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
int id = info[0].As<Napi::Number>().Int32Value();
const bool useConptyDll = info[1].As<Napi::Boolean>().Value();
- const pty_baton* handle = get_pty_baton(id);
-
- if (handle != nullptr) {
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
- if (fLoadedDll)
+ // Orca: resolve the DLL BEFORE touching any baton state, for the same reason
+ // PtyConnect does it before creating anything. LoadConptyDll throws when
+ // conpty.dll is missing, and a throw after consoleClosed was set would strand
@@ -794,18 +830,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
+ (HMODULE)hLibrary,
+ useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
+ }
- if (handle != nullptr) {
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
- if (fLoadedDll)
- {
- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
- (HMODULE)hLibrary,
- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
- if (pfnClosePseudoConsole)
- {
- pfnClosePseudoConsole(handle->hpc);
+
+ // Orca: the baton now outlives the shell, so this runs on a self-exited pty
+ // too -- that is the whole point. Take what we need under the lock: the
+ // watcher thread nulls hShell the moment the shell dies, and TerminateProcess
@@ -841,18 +866,26 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
+ const bool removed = remove_pty_baton(id);
+ assert(removed);
+ (void)removed;
}
+ }
+ // Else the shell is still running and the watcher frees the baton.
}
- if (useConptyDll) {
- TerminateProcess(handle->hShell, 1);
+ }
+ }
+
+ // Why outside the lock: ClosePseudoConsole blocks until the conout side has
+ // drained, and the watcher must be able to take the lock while it does.
+ if (owed) {
+ if (pfnClosePseudoConsole)
+ {
{
- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
- (HMODULE)hLibrary,
- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
- if (pfnClosePseudoConsole)
- {
- pfnClosePseudoConsole(handle->hpc);
- }
- }
- if (useConptyDll) {
- TerminateProcess(handle->hShell, 1);
+ pfnClosePseudoConsole(hpc);
+ }
+ if (hShellDup != nullptr) {
@@ -862,8 +895,8 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
}
return env.Undefined();
}
+}
+
+/**
+ * Orca: confirm a baton really is the pty the caller means.
+ *
@@ -1001,12 +1034,10 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c9
+ }
+ hHostJob = job;
+ return Napi::Boolean::New(env, true);
+}
+
}
/**
* Init
*/
@@ -577,6 +867,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
@@ -577,6 +884,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("clear", Napi::Function::New(env, PtyClear));
exports.Set("kill", Napi::Function::New(env, PtyKill));
+69
View File
@@ -10,6 +10,75 @@
}
},
"gates": [
{
"id": "terminal-performance.padded-fullscreen-redraw",
"title": "Fullscreen redraw padding does not stall terminal delivery",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "runtime-unit-and-electron-cdp",
"surfaces": ["terminal transcript preview", "fullscreen TUI scrolling"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon"],
"coverageNotes": "The trim operation is platform-independent and preserves the same spaces/tabs policy for all providers. Real Pi 0.84.2 was exercised in a hidden macOS Electron renderer through CDP using a folder workspace.",
"motivatingLinks": ["https://github.com/stablyai/orca/issues/14770"],
"invariant": "Transcript preview trimming preserves internal whitespace and terminal read contents without quadratic main-process work on padded fullscreen redraws.",
"oracle": "Preserve 32,000 spaces before a marker while trimming trailing spaces/tabs in both retained-row and carried-prefix redraw paths; four redraws must finish within 500 ms. Existing tail equivalence tests preserve cursor, retention, and pagination behavior.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/terminal-tail-whitespace.test.ts src/main/runtime/terminal-tail-buffer.test.ts src/main/runtime/retained-tail-redraw-window.equivalence.test.ts"
],
"testFiles": [
"src/main/runtime/terminal-tail-whitespace.test.ts",
"src/main/runtime/terminal-tail-buffer.test.ts",
"src/main/runtime/retained-tail-redraw-window.equivalence.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/terminal-tail-whitespace.test.ts",
"assertions": [
"handles padded redraws across %i retained rows without stalling",
"preserves terminal text while trimming spaces and tabs: %j"
]
}
],
"evidenceRuns": [
{
"date": "2026-09-06",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/terminal-tail-whitespace.test.ts src/main/runtime/terminal-tail-buffer.test.ts src/main/runtime/retained-tail-redraw-window.equivalence.test.ts",
"result": "passed",
"durationSeconds": 3.96,
"summary": "17 tests passed. Before the fix both padding budget cases failed, taking approximately 1.7 seconds each."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "Three unit test files; padding cases allow 500 ms for four redraws."
},
"flakeHistory": {
"status": "not-started",
"evidence": "Initial local red/green validation; no CI soak history yet."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Both padding budget cases fail with regex trimming and pass with the existing linear trim. A 60-event CDP wheel stream in Pi fullscreen had about 2.1 seconds of output tail before the fix and 14 ms after rebuilding."
},
"performanceBudget": {
"required": true,
"evidence": "The main CPU profile attributed 3.1 seconds to redraw-row whitespace trimming. Reusing the linear trim adds no timers, caches, provider calls, or output dropping."
},
"knownGaps": [
"The user manually compared the fixed dev app with production and confirmed improved responsiveness. A live Terminal.app comparison was not exercised; timing measurements used CDP wheel events.",
"Linux, Windows, and live SSH rendering were not exercised; the shared trimming behavior is covered by unit tests."
],
"promotionCriteria": [
"Complete CI soak requirements and retain the padding budget and tail equivalence oracles."
],
"demotionRule": "Keep experimental until CI soak is stable; investigate any budget failure without weakening transcript preservation."
},
{
"id": "ssh.localhost-terminal-agent-hooks",
"title": "Localhost SSH terminal and agent hooks reach the owning pane",
+1
View File
@@ -224,6 +224,7 @@ const WINDOWS_PACKAGE_TESTS = [
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts',
'src/main/windows/windows-pty-job.win32.test.ts',
'src/main/windows/windows-msys-job.win32.test.ts',
'src/main/windows/windows-host-job.win32.test.ts',
'src/main/windows/windows-process-tree-command-line-patch.test.ts',
'src/main/windows/windows-process-table-native-addon.win32.test.ts',
@@ -575,6 +575,20 @@ running, so typing `exit` in a pane reaped a `start /b` server that used to
survive. The job exists to make an _explicit_ teardown exact, not to redefine
what a clean exit means.
Git Bash needs one additional restriction. The Cygwin runtime — and the MSYS2
fork of it that Git for Windows ships — reads `JOB_OBJECT_LIMIT_BREAKAWAY_OK`
off its own job and then adds `CREATE_BREAKAWAY_FROM_JOB` to **every** child it
spawns when that flag is set (`spawn.cc`, there since 2011), so offering
breakaway hands the whole tree its escape. The per-PTY job therefore omits
`BREAKAWAY_OK` whenever `msys-2.0.dll` or `cygwin1.dll` sits on the shell's DLL
search path — beside the executable, or under `usr/bin` for Git's `bin`
launcher. Native shells keep explicit breakaway. Denying it costs Cygwin
nothing, because it *pre-checks* the limit rather than retrying, so no spawn
fails; but a *native* program that passes `CREATE_BREAKAWAY_FROM_JOB` itself
inside such a pane now gets `ERROR_ACCESS_DENIED`. `nohup` and `disown` are
unaffected — they are Cygwin signal/session concepts, unrelated to job
membership. The daemon's host job is unchanged.
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
job**, not this one. The terminal daemon assigns itself to a kill-on-close job
at startup (`assignHostProcessToKillOnCloseJob`); children inherit membership,
@@ -27,11 +27,24 @@ vi.mock('react-native', () => ({
vi.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', ChevronRight: 'ChevronRight' }))
vi.mock('../transport/client-context', () => ({ useForceReconnect: () => vi.fn() }))
// Captured at module scope: the list renders rows against Date.now() a few ms later,
// so a 3h offset stays inside the '3h' relative-time bucket.
const RENDER_NOW = Date.now()
function historyResponse(subject: string) {
return {
ok: true,
result: {
items: [{ id: 'commit-1', displayId: 'c0mm1t1', subject, author: 'Ada', parentIds: [] }]
items: [
{
id: 'commit-1',
displayId: 'c0mm1t1',
subject,
author: 'Ada',
parentIds: [],
timestamp: RENDER_NOW - 3 * 3_600_000
}
]
}
}
}
@@ -92,6 +105,9 @@ describe('MobileGitHistoryList', () => {
await render(client, 'connected')
expect(tree()).toContain('first load')
// Rows format the RPC timestamp (epoch ms); a regression to seconds-scaling
// renders every commit as 'just now' instead.
expect(tree()).toContain('3h')
await update(client, 'reconnecting')
expect(tree()).toContain('first load')
@@ -11,20 +11,21 @@ function item(overrides: Partial<GitHistoryItem> = {}): GitHistoryItem {
subject: 'feat: thing',
message: 'feat: thing\n\nbody',
author: 'Jane',
timestamp: NOW / 1000 - 3600,
timestamp: NOW - 3_600_000,
...overrides
}
}
describe('formatCommitTime', () => {
it('formats across thresholds', () => {
const s = NOW / 1000
expect(formatCommitTime(s - 30, NOW)).toBe('just now')
expect(formatCommitTime(s - 5 * 60, NOW)).toBe('5m')
expect(formatCommitTime(s - 3 * 3600, NOW)).toBe('3h')
expect(formatCommitTime(s - 2 * 86400, NOW)).toBe('2d')
expect(formatCommitTime(s - 60 * 86400, NOW)).toBe('2mo')
expect(formatCommitTime(s - 800 * 86400, NOW)).toBe('2y')
it('formats across thresholds from epoch-millisecond timestamps', () => {
// GitHistoryItem.timestamp is epoch ms (git-history-log-parser scales git %at by 1000).
const ms = { min: 60_000, hour: 3_600_000, day: 86_400_000 }
expect(formatCommitTime(NOW - 3 * ms.hour, NOW)).toBe('3h')
expect(formatCommitTime(NOW - 30_000, NOW)).toBe('just now')
expect(formatCommitTime(NOW - 5 * ms.min, NOW)).toBe('5m')
expect(formatCommitTime(NOW - 2 * ms.day, NOW)).toBe('2d')
expect(formatCommitTime(NOW - 60 * ms.day, NOW)).toBe('2mo')
expect(formatCommitTime(NOW - 800 * ms.day, NOW)).toBe('2y')
})
it('returns empty for missing timestamp', () => {
@@ -12,12 +12,13 @@ export type MobileCommitRow = {
}
// Short relative time for a commit list (just now / Xm / Xh / Xd / Xmo / Xy).
export function formatCommitTime(timestampSeconds: number | undefined, nowMs: number): string {
// `timestampMs` is epoch ms, the unit GitHistoryItem.timestamp already carries.
export function formatCommitTime(timestampMs: number | undefined, nowMs: number): string {
// Nullish — not falsy — so a real epoch-0 timestamp still formats.
if (timestampSeconds == null) {
if (timestampMs == null) {
return ''
}
const delta = nowMs - timestampSeconds * 1000
const delta = nowMs - timestampMs
if (delta < 60_000) {
return 'just now'
}
+3 -3
View File
@@ -116,7 +116,7 @@ patchedDependencies:
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: 7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1
node-pty@1.1.0: bac3a53fb15efc9b3b944fbe3c4718b5174a0b3bd6ead84e21975edad4bc6615
importers:
@@ -160,7 +160,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1)
version: 1.1.0(patch_hash=bac3a53fb15efc9b3b944fbe3c4718b5174a0b3bd6ead84e21975edad4bc6615)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12285,7 +12285,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1):
node-pty@1.1.0(patch_hash=bac3a53fb15efc9b3b944fbe3c4718b5174a0b3bd6ead84e21975edad4bc6615):
dependencies:
node-addon-api: 7.1.1
@@ -0,0 +1,37 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture'
// Why: these values predate buildDefaultSettings' current defaults; codex-account suites assert against them.
export function createCodexAccountSettings(
workspaceDir: string,
overrides: Partial<GlobalSettings> = {}
): GlobalSettings {
return createGlobalSettingsFixture({
workspaceDir,
nestWorkspaces: false,
autoRenameBranchFromWork: false,
terminalCursorBlink: false,
terminalThemeDark: 'orca-dark',
terminalDividerColorDark: '#000000',
terminalUseSeparateLightTheme: false,
terminalThemeLight: 'orca-light',
terminalDividerColorLight: '#ffffff',
terminalPaneOpacityTransitionMs: 150,
terminalDividerThicknessPx: 1,
setupScriptLaunchMode: 'split-vertical',
localAccountRuntime: 'host',
floatingTerminalEnabled: false,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalActivity: true,
terminalWindowsPowerShellImplementation: 'powershell.exe',
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
diffShowWhitespace: overrides.diffShowWhitespace ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily: overrides.appFontFamily ?? 'Geist',
agentStatusHooksEnabled: overrides.agentStatusHooksEnabled ?? true,
tabAutoGenerateTitle: overrides.tabAutoGenerateTitle ?? false
})
}
@@ -1,4 +1,5 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import { createCodexAccountSettings } from './codex-account-settings-fixture'
import {
setShellStartupEnvProbeSupportedForTest,
testState
@@ -12,130 +13,8 @@ type TestSettingsOverrides = Partial<GlobalSettings> & {
}
export function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true
const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false
// Mirror-path tests assert the shared runtime home, which production still uses
// on Windows; opt these cases onto that lane unless a test overrides it.
setShellStartupEnvProbeSupportedForTest(overrides.shellStartupEnvProbeSupported ?? false)
return {
workspaceDir: testState.fakeHomeDir,
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
localBaseRefSuggestionDismissed: false,
autoRenameBranchFromWork: false,
branchPrefix: 'git-username',
branchPrefixCustom: '',
theme: 'system',
uiLanguage: 'system',
appIcon: overrides.appIcon ?? 'classic',
editorAutoSave: false,
editorAutoSaveDelayMs: 1000,
editorMinimapEnabled: false,
markdownReviewToolsEnabled: true,
terminalFontSize: 14,
terminalFontFamily: 'JetBrains Mono',
terminalFontWeightBold: 700,
terminalFontWeight: 500,
terminalLineHeight: 1,
terminalScrollSensitivity: 1.15,
terminalFastScrollSensitivity: 5,
terminalTuiScrollSensitivity: 1,
terminalGpuAcceleration: 'auto',
terminalLigatures: 'auto',
terminalCursorStyle: 'block',
terminalCursorBlink: false,
terminalThemeDark: 'orca-dark',
terminalDividerColorDark: '#000000',
terminalUseSeparateLightTheme: false,
terminalThemeLight: 'orca-light',
terminalDividerColorLight: '#ffffff',
terminalInactivePaneOpacity: 0.5,
terminalActivePaneOpacity: 1,
terminalPaneOpacityTransitionMs: 150,
terminalDividerThicknessPx: 1,
terminalRightClickToPaste: false,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: true,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackRows: 5_000,
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
sourceControlGroupOrder: 'changes-first',
sourceControlCompareAgainstUpstream: false,
showTitlebarAppName: true,
showTasksButton: true,
floatingTerminalEnabled: false,
floatingTerminalCwd: '~',
floatingTerminalTriggerLocation: 'floating-button',
diffDefaultView: 'inline',
combinedDiffFileTreeVisibleByDefault: false,
prBotAuthorOverrides: [],
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundId: 'system',
customSoundPath: null,
customSoundVolume: 100
},
promptCacheTimerEnabled: false,
promptCacheTtlMs: 300_000,
codexManagedAccounts: [],
activeCodexManagedAccountId: null,
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
disabledTuiAgents: [],
pluginSystemEnabled: false,
disabledPlugins: [],
pluginConsents: {},
devPluginPaths: [],
skipDeleteWorktreeConfirm: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
skipDeleteArtifactConfirm: false,
skipCodexRateLimitResetConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: ['github', 'gitlab', 'linear', 'jira'],
visibleTaskProvidersDefaultedForJira: true,
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
minimaxEndpoint: 'overseas',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
confirmClosePinnedTab: true,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
terminalJISYenToBackslash: false,
experimentalMobile: false,
mobileAutoRestoreFitMs: null,
experimentalPet: false,
experimentalActivity: true,
experimentalTerminalAttention: false,
compactWorktreeCards: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
diffShowWhitespace: overrides.diffShowWhitespace ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
agentStatusHooksEnabled,
tabAutoGenerateTitle
}
return createCodexAccountSettings(testState.fakeHomeDir, overrides)
}
@@ -1,4 +1,5 @@
import type { ProviderRateLimits, RateLimitState } from '../../shared/rate-limit-types'
import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory'
export function createResetCreditLimits(updatedAt = 30): ProviderRateLimits {
return {
@@ -26,21 +27,5 @@ export function createResetRateLimitState(
codex: ProviderRateLimits,
target: RateLimitState['codexTarget'] = { runtime: 'host', wslDistro: null }
): RateLimitState {
return {
claude: null,
codex,
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null,
minimaxCookieConfigured: false,
minimaxApiKeyConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: target,
inactiveClaudeAccounts: [],
inactiveCodexAccounts: []
}
return createEmptyRateLimitState({ codex, codexTarget: target })
}
+2 -123
View File
@@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { createCodexAccountSettings } from './codex-account-settings-fixture'
import type { CodexResetCreditAttemptLedger } from '../../shared/codex-reset-credit-attempt-ledger'
import type { CodexRateLimitHomeResolution } from './runtime-home-service'
@@ -36,129 +37,7 @@ export function registerCodexAccountsTestHomes(): void {
}
export function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true
const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false
return {
workspaceDir: testState.fakeHomeDir,
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
localBaseRefSuggestionDismissed: false,
autoRenameBranchFromWork: false,
branchPrefix: 'git-username',
branchPrefixCustom: '',
theme: 'system',
uiLanguage: 'system',
appIcon: overrides.appIcon ?? 'classic',
editorAutoSave: false,
editorAutoSaveDelayMs: 1000,
editorMinimapEnabled: false,
markdownReviewToolsEnabled: true,
terminalFontSize: 14,
terminalFontFamily: 'JetBrains Mono',
terminalFontWeightBold: 700,
terminalFontWeight: 500,
terminalLineHeight: 1,
terminalScrollSensitivity: 1.15,
terminalFastScrollSensitivity: 5,
terminalTuiScrollSensitivity: 1,
terminalGpuAcceleration: 'auto',
terminalLigatures: 'auto',
terminalCursorStyle: 'block',
terminalCursorBlink: false,
terminalThemeDark: 'orca-dark',
terminalDividerColorDark: '#000000',
terminalUseSeparateLightTheme: false,
terminalThemeLight: 'orca-light',
terminalDividerColorLight: '#ffffff',
terminalInactivePaneOpacity: 0.5,
terminalActivePaneOpacity: 1,
terminalPaneOpacityTransitionMs: 150,
terminalDividerThicknessPx: 1,
terminalRightClickToPaste: false,
terminalFocusFollowsMouse: false,
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: true,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackRows: 5_000,
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
sourceControlGroupOrder: 'changes-first',
sourceControlCompareAgainstUpstream: false,
showTitlebarAppName: true,
showTasksButton: true,
floatingTerminalEnabled: false,
floatingTerminalCwd: '~',
floatingTerminalTriggerLocation: 'floating-button',
diffDefaultView: 'inline',
combinedDiffFileTreeVisibleByDefault: false,
prBotAuthorOverrides: [],
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: false,
suppressWhenFocused: true,
customSoundId: 'system',
customSoundPath: null,
customSoundVolume: 100
},
promptCacheTimerEnabled: false,
promptCacheTtlMs: 300_000,
codexManagedAccounts: [],
activeCodexManagedAccountId: null,
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
disabledTuiAgents: [],
pluginSystemEnabled: false,
disabledPlugins: [],
pluginConsents: {},
devPluginPaths: [],
skipDeleteWorktreeConfirm: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
skipDeleteArtifactConfirm: false,
skipCodexRateLimitResetConfirm: false,
defaultTaskViewPreset: 'all',
defaultTaskSource: 'github',
visibleTaskProviders: ['github', 'gitlab', 'linear', 'jira'],
visibleTaskProvidersDefaultedForJira: true,
defaultRepoSelection: null,
defaultLinearTeamSelection: null,
opencodeSessionCookie: '',
opencodeWorkspaceId: '',
minimaxGroupId: '',
minimaxUsageModels: 'general',
minimaxEndpoint: 'overseas',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
confirmClosePinnedTab: true,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
terminalJISYenToBackslash: false,
experimentalMobile: false,
mobileAutoRestoreFitMs: null,
experimentalPet: false,
experimentalActivity: true,
experimentalTerminalAttention: false,
compactWorktreeCards: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
...overrides,
diffWordWrap: overrides.diffWordWrap ?? false,
diffShowWhitespace: overrides.diffShowWhitespace ?? false,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
appFontFamily,
agentStatusHooksEnabled,
tabAutoGenerateTitle
}
return createCodexAccountSettings(testState.fakeHomeDir, overrides)
}
export function createStore(settings: GlobalSettings) {
+1 -1
View File
@@ -32,7 +32,7 @@ vi.mock('../minimax/minimax-api-key-store', () => ({
hasMiniMaxApiKey: hasMiniMaxApiKeyMock
}))
vi.mock('../rate-limits/minimax-request-context', () => ({
vi.mock('../rate-limits/minimax/minimax-request-context', () => ({
clearMiniMaxSessionCookieJar: clearMiniMaxSessionCookieJarMock
}))
+1 -1
View File
@@ -9,7 +9,7 @@ import {
hasMiniMaxApiKey,
saveMiniMaxApiKey
} from '../minimax/minimax-api-key-store'
import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context'
import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax/minimax-request-context'
import type { RateLimitService } from '../rate-limits/service'
export type MiniMaxCredentialsStatus = {
+1
View File
@@ -147,6 +147,7 @@ describe('registerRuntimeHandlers', () => {
}
const runtime = {
getRuntimeId: vi.fn().mockReturnValue('runtime-1'),
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(async () => undefined),
listMobileSessionTabs: vi.fn(async () => ({
worktree: 'workspace-1',
@@ -70,6 +70,7 @@ async function failingFlowRunner(
throw new Error('unused')
},
importTuiHistory: async () => {},
retryPendingSettlement: async () => true,
publish: () => {},
schedule: async () => {
throw new Error('scheduling failed')
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types'
import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close'
const NOW = 1_800_000_000_000
describe('closeRetainedTuiOwner', () => {
it('does not latch unexpected-exit settlement after an intentional close', async () => {
let record = agentSessionRecordFixture(agentSessionLeaseFixture())
const closeTuiOwner = vi.fn(async () => ({}))
const releaseOwner = vi.fn()
const owner = {
terminal: { handle: 'terminal-1', tabId: 'tab-1', paneKey: 'pane-1', ptyId: 'pty-1' },
process: record.lease.ownerProcess!,
link: record.providerHandleChain[0]!
}
const deps = {
store: {
transitionHandoff: async (
_sessionId: string,
transition: (current: typeof record) => typeof record
) => {
record = transition(record)
return record
}
},
transport: { closeTuiOwner },
now: () => NOW
} as unknown as StructuredAgentSessionHandoffDeps
await closeRetainedTuiOwner({
sessionId: record.sessionId,
deps,
owner: () => owner,
requireRecord: () => record,
releaseOwner
})
expect(closeTuiOwner).toHaveBeenCalledWith(owner)
expect(releaseOwner).toHaveBeenCalledWith(record.sessionId)
expect(record.lease).toMatchObject({
claimStatus: 'released',
deathEvidence: { kind: 'exit-observed' }
})
expect(record.lease.settlementRetryRequired).toBeUndefined()
expect(record.lease.settlementRetryId).toBeUndefined()
})
})
@@ -26,7 +26,8 @@ export async function closeRetainedTuiOwner(input: {
record: current,
expectedFence: record.lease.runtimeFence,
probe: { outcome: 'exit-observed' },
now: input.deps.now()
now: input.deps.now(),
journalSettlement: 'not-required'
})
)
input.releaseOwner(input.sessionId)
@@ -95,6 +95,13 @@ export async function handoffStructuredSessionToNative(
...(transcriptPath ? { transcriptPath } : {})
})
}
if (record.lease.settlementRetryRequired) {
const settled = await deps.retryPendingSettlement(sessionId)
if (!settled) {
throw new Error('The provider-exit terminal journal settlement is still pending.')
}
record = context.requireRecord(sessionId)
}
const spawnToken = randomUUID()
record = await reserveStoredAgentSessionHandoffOwner(deps.store, {
sessionId,
@@ -73,6 +73,7 @@ export function createStructuredAgentSessionHandoffTestCoordinator(
{ fence, recovered: true }
)
},
retryPendingSettlement: async () => true,
publish: (_sessionId, status) => input.statuses.push(status),
schedule: async (_sessionId, task) => task(),
now: () => input.now
@@ -81,6 +81,7 @@ export type StructuredAgentSessionHandoffDeps = {
fence: number
transcriptPath?: string
}) => Promise<void>
retryPendingSettlement: (sessionId: string) => Promise<boolean>
prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise<void>
recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise<void>
activateTuiHistoryCatchup?: (sessionId: string) => Promise<void>
@@ -179,6 +179,7 @@ function createCoordinator(): StructuredAgentSessionHandoffCoordinator {
{ fence, recovered: true }
)
},
retryPendingSettlement: async () => true,
prepareTuiHistoryCatchup,
recoverTuiHistoryCatchup,
activateTuiHistoryCatchup,
@@ -274,6 +275,7 @@ describe('structured session handoff failure handling', () => {
}),
acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId),
importTuiHistory: vi.fn(async () => undefined),
retryPendingSettlement: vi.fn(async () => true),
prepareTuiHistoryCatchup,
recoverTuiHistoryCatchup,
activateTuiHistoryCatchup,
@@ -14,6 +14,7 @@ import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
import type { AgentSessionSubscribers } from './structured-agent-session-subscribers'
import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup'
import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
type HostHandoffAccess = {
session: (sessionId: string) => StructuredAgentSessionHostSession
@@ -92,6 +93,13 @@ export function createStructuredAgentSessionHostHandoff(
acquireNativeStop: async (sessionId, turnId, fence) =>
(await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled,
importTuiHistory: (input) => importTuiHistory(deps, host, input),
retryPendingSettlement: (sessionId) =>
retryLoadedStructuredAgentSessionSettlement({
deps,
sessionId,
session: host.session(sessionId),
now: host.now
}),
prepareTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.prepare(sessionId, fence),
recoverTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.recover(sessionId, fence),
activateTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.activate(sessionId),
@@ -127,7 +127,7 @@ export class StructuredAgentSessionHost {
),
evict: (sessionId) => this.close(sessionId)
})
this.restore = createStructuredAgentSessionHostRestore(deps, {
this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), {
reconcile: this.reconcileLeases,
resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
@@ -104,6 +104,7 @@ describe('structured session live TUI restart survival', () => {
suspendNative: vi.fn(),
acquireNative: vi.fn(),
importTuiHistory: vi.fn(),
retryPendingSettlement: vi.fn(async () => true),
publish: vi.fn(),
schedule: async (_sessionId, task) => task(),
now: () => NOW
@@ -224,6 +225,7 @@ describe('structured session live TUI restart survival', () => {
suspendNative: vi.fn(),
acquireNative: vi.fn(),
importTuiHistory: vi.fn(),
retryPendingSettlement: vi.fn(async () => true),
publish: vi.fn(),
schedule: async (_sessionId, task) => task(),
now: () => NOW
@@ -3,12 +3,14 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions'
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types'
import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
const NOW = 1_800_000_000_000
const SESSION = 'session-proven-dead-retry'
@@ -89,6 +91,15 @@ describe('structured session proven-dead TUI retry', () => {
},
journalDir: join(root, 'journal')
})
await journal.appendItem(
{ provider: 'orca', clientMessageId: 'running-turn' },
{
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
},
{ fence: store.getRecord(SESSION)?.lease.runtimeFence ?? tuiFence }
)
const closeTuiOwner =
vi.fn<NonNullable<StructuredAgentSessionHandoffTransport['closeTuiOwner']>>()
const coordinator = new StructuredAgentSessionHandoffCoordinator({
@@ -134,6 +145,17 @@ describe('structured session proven-dead TUI retry', () => {
},
acquireNativeStop: vi.fn(async () => true),
importTuiHistory: vi.fn(),
retryPendingSettlement: (sessionId) =>
retryLoadedStructuredAgentSessionSettlement({
deps: { store },
sessionId,
session: {
journal,
fence: store.getRecord(sessionId)?.lease.runtimeFence ?? 1,
acquisitionGeneration: null
},
now: () => NOW
}),
publish: vi.fn(),
schedule: async (_sessionId, task) => task(),
now: () => NOW
@@ -174,5 +196,7 @@ describe('structured session proven-dead TUI retry', () => {
claimStatus: 'live',
handoffStage: null
})
expect(store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined()
expect(activeStructuredAgentSessionTurnId(journal.snapshot().items)).toBe(null)
})
})
@@ -27,6 +27,7 @@ describe('StructuredAgentSessionReadableRestorer', () => {
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => undefined,
retrySettlement: async () => true,
restoreHandoff: async () => undefined
})
@@ -20,6 +20,10 @@ export class StructuredAgentSessionReadableRestorer {
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
hasSession: (sessionId: string) => boolean
onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void
retrySettlement: (
sessionId: string,
params: RestoredStructuredAgentSessionRead['params']
) => Promise<boolean>
restoreHandoff: (sessionId: string) => Promise<void>
}
) {}
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
const { restoreRead } = vi.hoisted(() => ({
restoreRead: vi.fn()
@@ -9,7 +10,10 @@ vi.mock('./structured-agent-session-read-restore', () => ({
restoreStructuredAgentSessionRead: restoreRead
}))
import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore'
import {
restoreOneStructuredAgentSessionRead,
restoreStructuredAgentSessionsOnRestart
} from './structured-agent-session-restart-restore'
describe('restart journal restoration', () => {
beforeEach(() => restoreRead.mockReset())
@@ -45,6 +49,7 @@ describe('restart journal restoration', () => {
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => undefined,
retrySettlement: async () => true,
restoreHandoff: async () => undefined
})
@@ -56,4 +61,96 @@ describe('restart journal restoration', () => {
expect(restoreRead).toHaveBeenCalledTimes(records.length)
expect(peak).toBe(4)
})
it('runs pending settlement retry after recovery resolution and before handoff', async () => {
const calls: string[] = []
const params: AgentSessionAttachParams = {
envelope: {
sessionId: 'session-1',
clientOperationId: 'read-restore:session-1',
expectedRuntimeFence: 4,
payloadFingerprint: 'fingerprint'
},
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' },
runtimeKind: 'native'
}
restoreRead.mockResolvedValue({
journal: {},
params,
fence: 4,
hasProviderChild: false,
acquisitionGeneration: null
})
await restoreOneStructuredAgentSessionRead(
{
store: {} as never,
journalRoot: '/tmp/journals',
reconcile: async () => null,
resolveRecovery: async () => {
calls.push('resolveRecovery')
},
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => {
calls.push('onReadable')
},
retrySettlement: async (_sessionId, restoredParams) => {
calls.push(
restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement'
)
return true
},
restoreHandoff: async () => {
calls.push('restoreHandoff')
}
},
'session-1'
)
expect(calls).toEqual([
'resolveRecovery',
'onReadable',
'retrySettlement:restored-params',
'restoreHandoff'
])
})
it('does not rerun settlement retry when a second restore finds the session already open', async () => {
const retrySettlement = vi.fn(async () => true)
const restoreHandoff = vi.fn(async () => undefined)
restoreRead.mockResolvedValue({
journal: {},
params: {},
fence: 4,
hasProviderChild: false,
acquisitionGeneration: null
})
await restoreOneStructuredAgentSessionRead(
{
store: {} as never,
journalRoot: '/tmp/journals',
reconcile: async () => null,
resolveRecovery: async () => undefined,
serialize: async (_sessionId, task) => task(),
hasSession: () => true,
onReadable: () => undefined,
retrySettlement,
restoreHandoff
},
'session-1'
)
expect(retrySettlement).not.toHaveBeenCalled()
expect(restoreHandoff).toHaveBeenCalledOnce()
})
})
@@ -30,6 +30,10 @@ export type StructuredAgentSessionReadRestoreDeps = {
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
hasSession: (sessionId: string) => boolean
onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void
retrySettlement: (
sessionId: string,
params: RestoredStructuredAgentSessionRead['params']
) => Promise<boolean>
restoreHandoff: (sessionId: string) => Promise<void>
}
@@ -64,6 +68,7 @@ export async function restoreOneStructuredAgentSessionRead(
return
}
input.onReadable(sessionId, restored)
await input.retrySettlement(sessionId, restored.params)
await input.restoreHandoff(sessionId)
})
}
@@ -58,6 +58,7 @@ function harness(
serialize,
hasSession: (sessionId) => live.has(sessionId),
onReadable: (sessionId, restored) => live.set(sessionId, restored),
retrySettlement: async () => true,
restoreHandoff
})
return { restorer, live, restoreHandoff, serializedIds }
@@ -16,8 +16,10 @@ import { StructuredAgentSessionReadableRestorer } from './structured-agent-sessi
import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate'
import type {
StructuredAgentSessionHostDeps,
StructuredAgentSessionHostSession,
StructuredAgentSessionReveal
} from './structured-agent-session-host-types'
import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
/** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */
export async function revealStructuredAgentSession(
@@ -55,9 +57,11 @@ export async function revealStructuredAgentSession(
*/
export function createStructuredAgentSessionHostRestore(
deps: StructuredAgentSessionHostDeps,
sessions: Map<string, StructuredAgentSessionHostSession>,
now: () => number,
wiring: Omit<
ConstructorParameters<typeof StructuredAgentSessionReadableRestorer>[0],
'store' | 'journalRoot' | 'supportsRecord'
'store' | 'journalRoot' | 'supportsRecord' | 'retrySettlement'
>
): {
restoreReadableSessions: (sessionIds?: readonly string[]) => Promise<void>
@@ -67,6 +71,8 @@ export function createStructuredAgentSessionHostRestore(
store: deps.store,
journalRoot: deps.journalRoot,
supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record),
retrySettlement: (sessionId, params) =>
retryPendingStructuredAgentSessionSettlement({ deps, sessions, sessionId, params, now }),
...wiring
})
const gate = new StructuredAgentSessionRestartRestoreGate()
@@ -46,15 +46,27 @@ export async function retryPendingStructuredAgentSessionSettlement(input: {
hasProviderChild: false,
acquisitionGeneration: null
} as StructuredAgentSessionHostSession)
return retryLoadedStructuredAgentSessionSettlement({
deps: input.deps,
sessionId: input.sessionId,
session: retrySession,
now: input.now
})
}
export async function retryLoadedStructuredAgentSessionSettlement(input: {
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'onEventSinkError'>
sessionId: string
session: Pick<StructuredAgentSessionHostSession, 'journal' | 'fence' | 'acquisitionGeneration'>
now: () => number
}): Promise<boolean> {
const record = input.deps.store.getRecord(input.sessionId)
if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) {
return true
}
const retrySession = input.session
retrySession.fence = record.lease.runtimeFence
const context: StructuredAgentSessionUnexpectedExitContext = {
store: input.deps.store,
sessions: input.sessions,
flushLifecycle: async () => ({ ok: true as const }),
publishFence: () => undefined,
hasResumeCapableHolder: () => false,
serialize: async <T>(_id: string, task: () => Promise<T>) => task(),
now: input.now,
const context: Pick<StructuredAgentSessionUnexpectedExitContext, 'onBarrierError'> = {
onBarrierError: (id, error) => input.deps.onEventSinkError?.({ sessionId: id, error })
}
const ok = await retryUnexpectedExitSettlement({
@@ -65,7 +77,7 @@ export async function retryPendingStructuredAgentSessionSettlement(input: {
reason: record.lease.deathEvidence?.detail ?? 'provider exited',
cause: 'unexpected-exit',
fence: record.lease.runtimeFence,
acquisitionGeneration: current?.acquisitionGeneration ?? 'recovery'
acquisitionGeneration: retrySession.acquisitionGeneration ?? 'recovery'
},
session: retrySession,
stableSettlementId: record.lease.settlementRetryId
@@ -81,11 +93,14 @@ export async function retryPendingStructuredAgentSessionSettlement(input: {
) {
throw new Error('agent_session_checkpoint_stale')
}
// A dead-TUI retry still needs its stopped-owner stage; recovery-only stages end here.
const preserveHandoff = latest.lease.handoffStage === 'old-owner-stopped'
return {
...latest,
lease: {
...latest.lease,
handoffStage: null,
handoffStage: preserveHandoff ? latest.lease.handoffStage : null,
handoffOperationId: preserveHandoff ? latest.lease.handoffOperationId : null,
settlementRetryRequired: undefined,
settlementRetryId: undefined,
lastRenewedAt: input.now()
@@ -74,6 +74,52 @@ describe('performCancel', () => {
])
})
it('keeps the running lifecycle when cancellation cannot be confirmed', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-'))
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
await journal.appendItem(
{
provider: 'legacy',
agent: 'codex',
sessionId: 'session-1',
recordId: 'turn-lifecycle:turn-1'
},
{
kind: 'status',
text: 'Agent is working…',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
},
{ fence: 1 }
)
const ctx: AgentSessionTurnContext = {
sessionId: 'session-1',
journal,
fence: 1,
adapter: {
cancelTurn: vi.fn(async () => ({ cancelled: false }))
} as unknown as StructuredAgentSessionAdapter,
persistOptions: async () => undefined,
resolvedBy: 'client-1',
publish: vi.fn(),
now: () => 1
}
const result = await performCancel(ctx, {
clientOperationId: 'cancel-unconfirmed-1',
turnId: 'turn-1'
})
expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } })
expect(journal.snapshot().items.map((item) => item.body)).toEqual([
{
kind: 'status',
text: 'Agent is working…',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
},
{ kind: 'status', text: 'The provider had already finished this turn.' }
])
})
it('stops background tasks without interrupting the foreground turn or writing a row', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-background-task-cancel-'))
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
@@ -161,9 +161,9 @@ export function isStructuredAgentSessionRecoveryTicketCurrent(
}
export async function retryUnexpectedExitSettlement(input: {
context: StructuredAgentSessionUnexpectedExitContext
context: Pick<StructuredAgentSessionUnexpectedExitContext, 'onBarrierError'>
event: UnexpectedExitLifecycleEvent
session: StructuredAgentSessionHostSession
session: Pick<StructuredAgentSessionHostSession, 'journal' | 'fence'>
stableSettlementId: string
}): Promise<boolean> {
try {
@@ -189,7 +189,7 @@ export async function retryUnexpectedExitSettlement(input: {
function unexpectedExitFallbackMutations(
event: UnexpectedExitLifecycleEvent,
session: StructuredAgentSessionHostSession,
session: Pick<StructuredAgentSessionHostSession, 'journal'>,
stableSettlementId: string
): JournalLifecycleMutationInput[] {
const mutations: JournalLifecycleMutationInput[] = []
@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { evaluateAgentSessionAcquisition } from '../../../shared/agent-session-lease-adjudication'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
import type {
AgentSessionClaimStatus,
AgentSessionHandoffStage,
@@ -25,6 +26,9 @@ import type {
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import {
@@ -126,7 +130,8 @@ function openHost(overrides: Partial<StructuredAgentSessionHostDeps> = {}): void
dispatch: vi.fn(),
cancelTurn: vi.fn(),
answerPrompt: vi.fn(),
setOption: vi.fn()
setOption: vi.fn(),
supportsCreate: () => true
} as unknown as StructuredAgentSessionAdapter,
journalRoot: root,
claimKeyId: 'key-1',
@@ -174,7 +179,149 @@ function isAcquirable(lease: NonNullable<ReturnType<typeof store.getRecord>>['le
)
}
async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise<void> {
const journal = await openAgentSessionJournal({
identity: {
sessionId: SESSION,
workspaceId: LOCATION.workspaceId,
hostId: LOCATION.executionHostId,
agent: provider,
providerHandle:
provider === 'codex'
? { kind: 'codex', threadId: THREAD }
: { kind: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }
},
journalDir: journalDirectoryFor(root, { workspaceId: LOCATION.workspaceId, sessionId: SESSION })
})
await journal.appendItem(
provider === 'codex'
? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 }
: { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' },
{
kind: 'status',
text: 'Agent is working...',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
},
{ fence: 13 }
)
await journal.close()
}
function restoredJournal(): AgentSessionJournal {
const restored = (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)
if (!restored) {
throw new Error('expected a restored session journal')
}
return restored.journal
}
describe('already-wedged profiles become usable on load', () => {
it.each(['codex', 'claude'] as const)(
'settles a wedged %s journal on boot without opening a provider child',
async (provider) => {
const record = wedgedRecord({
claimStatus: 'live',
handoffStage: null,
ownerProcess: DEAD_OWNER
})
const providerRecord: AgentSessionRecord =
provider === 'codex'
? record
: {
...record,
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' },
lease: { ...record.lease, provenHandleLinkId: 'claude-13-link' },
providerHandleChain: [
{
linkId: 'claude-13-link',
handle: {
provider: 'claude',
sessionId: 'provider-session-alpha-1',
leafUuid: null
},
origin: 'created',
mintedAtFence: 13,
observedAt: NOW - 10_000
}
]
}
await seedStore(providerRecord)
await seedRunningTurn(provider)
openHost()
await host.restoreReadableSessions()
expect(host.hasSession(SESSION)).toBe(true)
const firstCursor = restoredJournal().cursor()
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'released',
handoffStage: null,
settlementRetryRequired: undefined,
settlementRetryId: undefined
})
expect(acquire).not.toHaveBeenCalled()
await host.flushAllStreamedEvents()
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
openHost()
await host.restoreReadableSessions()
expect(restoredJournal().cursor()).toEqual(firstCursor)
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
}
)
it('settles restart eviction through attach when a hold arrives before the boot sweep', async () => {
await seedStore(
wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER })
)
await seedRunningTurn()
openHost()
await host.hold(SESSION, 'desktop-chat:restart')
expect(acquire).toHaveBeenCalledOnce()
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'live',
handoffStage: null,
settlementRetryRequired: undefined,
settlementRetryId: undefined
})
})
it('settles an observed-exit latch through attach before the boot sweep', async () => {
const record = wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' })
record.lease.settlementRetryRequired = true
record.lease.settlementRetryId = `provider-exit:${SESSION}:12:generation-1`
record.lease.deathEvidence = {
kind: 'exit-observed',
detail: 'provider exited: transport closed',
observedAt: NOW - 1_000
}
await seedStore(record)
await seedRunningTurn()
openHost()
expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true })
expect(acquire).toHaveBeenCalledOnce()
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'live',
handoffStage: null,
settlementRetryRequired: undefined,
settlementRetryId: undefined
})
})
it('re-adjudicates a conflicted manual-recovery record whose owner is provably gone', async () => {
// A crash can leave a conflicted current-schema row in manual recovery; positive death proof
// must make it acquirable again without discarding the provider handle.
@@ -17,7 +17,7 @@ const AUDITED_NON_NET_FETCH_CALLS = new Map<string, number>([
['main/rate-limits/opencode-go-usage-fetcher.ts', 2],
// Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a
// regression: no proxy has ever reached this partition. Keep it listed so it stays visible.
['main/rate-limits/minimax-request-context.ts', 2],
['main/rate-limits/minimax/minimax-request-context.ts', 2],
// Injected HttpClient, not a session: resolves to net.fetch on defaultSession
// (main/host/electron-http-client.ts) or to the global-fetch-audited Node fallback.
['main/jira/authenticated-request.ts', 1]
@@ -1,4 +1,4 @@
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import type { ProviderRateLimits, RateLimitWindow } from '../../../shared/rate-limit-types'
// Why: pure data-shape helpers for the MiniMax Coding Plan API. Lives in its
// own file so both minimax-fetcher.ts (transport) and minimax-fetcher-parse.ts
@@ -1,4 +1,4 @@
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import type { ProviderRateLimits } from '../../../shared/rate-limit-types'
import {
logMiniMaxFetchFailure,
redactMiniMaxSecret,
@@ -1,5 +1,5 @@
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import type { MiniMaxEndpoint } from '../../shared/global-settings-types'
import type { ProviderRateLimits } from '../../../shared/rate-limit-types'
import type { MiniMaxEndpoint } from '../../../shared/global-settings-types'
import {
extractMiniMaxCookieValue,
fetchMiniMaxWithApiKey,
@@ -1,5 +1,5 @@
import { net, session, type Session } from 'electron'
import type { MiniMaxEndpoint } from '../../shared/global-settings-types'
import type { MiniMaxEndpoint } from '../../../shared/global-settings-types'
const MINIMAX_USAGE_PATH = '/v1/api/openplatform/coding_plan/remains'
const MINIMAX_OVERSEAS_BASE = 'https://platform.minimax.io'
@@ -5,7 +5,7 @@ import type { RateLimitService } from './service'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { readGrokAuthSession } from './grok-auth'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
@@ -32,7 +32,7 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -31,7 +31,7 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -39,7 +39,7 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -36,7 +36,7 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -3,7 +3,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import { RateLimitService } from './service'
import { fetchClaudeRateLimits } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import {
deferred,
@@ -33,7 +33,7 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -5,7 +5,7 @@ import { fetchClaudeRateLimits } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { readGrokAuthSession } from './grok-auth'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
@@ -40,7 +40,7 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -5,7 +5,7 @@ import { fetchClaudeRateLimits } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import {
@@ -41,7 +41,7 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
vi.mock('./minimax-fetcher', () => ({
vi.mock('./minimax/minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
@@ -3,7 +3,7 @@ import { fetchCodexRateLimits } from '../codex-fetcher'
import { fetchGeminiRateLimits } from '../gemini-usage-fetcher'
import { fetchGrokRateLimits } from '../grok-fetcher'
import { readGrokAuthSession } from '../grok-auth'
import { fetchMiniMaxRateLimits } from '../minimax-fetcher'
import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher'
import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher'
import { RateLimitServiceFetchPolicy } from './service-fetch-policy'
import type {
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import { evictAgentSessionOwner } from './agent-session-lease-transitions'
import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions'
const NOW = 1_800_000_000_000
describe('proven-dead agent session eviction settlement', () => {
it('latches restart eviction with a stable id while keeping the lease resumable', () => {
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true })
)
const evicted = applyAgentSessionRestartAdjudication({
record,
probe: { outcome: 'pid-absent' },
now: NOW
})
expect(evicted.lease).toMatchObject({
claimStatus: 'released',
runtimeFence: 8,
handoffStage: null,
settlementRetryRequired: true,
settlementRetryId: 'restart-eviction:session-alpha-1:8',
deathEvidence: { kind: 'pid-absent', detail: 'recorded pid absent on host' }
})
})
it('latches recovery eviction from the same evicted disposition', () => {
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' })
)
const evicted = evictAgentSessionOwner({
record,
expectedFence: 7,
probe: { outcome: 'identity-mismatch', field: 'process-start-time' },
now: NOW,
journalSettlement: 'required'
})
expect(evicted.lease).toMatchObject({
claimStatus: 'released',
runtimeFence: 8,
handoffStage: null,
settlementRetryRequired: true,
settlementRetryId: 'restart-eviction:session-alpha-1:8',
deathEvidence: { kind: 'identity-mismatch', detail: 'mismatched process-start-time' }
})
})
it('never latches an indeterminate owner', () => {
const restartRecord = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true })
)
const recovered = applyAgentSessionRestartAdjudication({
record: restartRecord,
probe: { outcome: 'indeterminate', reason: 'remote host unavailable' },
now: NOW
})
const recoveryRecord = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' })
)
expect(recovered.lease).toMatchObject({
handoffStage: 'recovering',
ownerProcess: { pid: 4242 }
})
expect(recovered.lease).not.toHaveProperty('settlementRetryRequired')
expect(recovered.lease).not.toHaveProperty('settlementRetryId')
expect(() =>
evictAgentSessionOwner({
record: recoveryRecord,
expectedFence: 7,
probe: { outcome: 'indeterminate', reason: 'remote host unavailable' },
now: NOW,
journalSettlement: 'required'
})
).toThrow('agent_session_ownership_unknown')
expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryRequired')
expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryId')
})
it('preserves a null handoff stage when the latch survives another restart', () => {
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({
runtimeKind: 'native',
ownerProcess: null,
reservedSpawnToken: null,
claimStatus: 'released',
handoffStage: null,
settlementRetryRequired: true,
settlementRetryId: 'restart-eviction:session-alpha-1:8',
unreconciled: true
})
)
const restored = applyAgentSessionRestartAdjudication({
record,
probe: { outcome: 'indeterminate', reason: 'remote host unavailable' },
now: NOW
})
expect(restored.lease).toMatchObject({
handoffStage: null,
settlementRetryRequired: true,
settlementRetryId: 'restart-eviction:session-alpha-1:8',
unreconciled: false
})
})
})
@@ -28,7 +28,7 @@ export function recoverDeadTuiOwnerForHandoff(args: {
) {
throw new Error('agent_session_ownership_unknown')
}
const evicted = evictAgentSessionOwner(args)
const evicted = evictAgentSessionOwner({ ...args, journalSettlement: 'required' })
return withLease(evicted, {
...evicted.lease,
handoffStage: 'old-owner-stopped',
@@ -8,6 +8,7 @@
import {
adjudicateAgentSessionRestart,
agentSessionRestartEvictionSettlementId,
evaluateAgentSessionAcquisition,
type AgentSessionOwnerProbe
} from '../../shared/agent-session-lease-adjudication'
@@ -207,6 +208,7 @@ export function evictAgentSessionOwner(args: {
expectedFence: number
probe: AgentSessionOwnerProbe
now: number
journalSettlement: 'required' | 'not-required'
}): AgentSessionRecord {
const { record } = args
assertFence(record.lease, args.expectedFence)
@@ -232,6 +234,7 @@ export function evictAgentSessionOwner(args: {
if (adjudication.disposition !== 'evicted') {
throw new Error('agent_session_ownership_unknown')
}
const settlementRequired = args.journalSettlement === 'required'
return withLease(record, {
...record.lease,
runtimeFence: adjudication.nextFence,
@@ -242,7 +245,11 @@ export function evictAgentSessionOwner(args: {
claimStatus: 'released',
lastRenewedAt: args.now,
handoffOperationId: null,
deathEvidence: adjudication.evidence
deathEvidence: adjudication.evidence,
settlementRetryRequired: settlementRequired ? true : undefined,
settlementRetryId: settlementRequired
? agentSessionRestartEvictionSettlementId(record.lease, adjudication)
: undefined
})
}
@@ -254,7 +254,9 @@ export class AgentSessionRecordStore {
probe: AgentSessionOwnerProbe
now: number
}): Promise<AgentSessionRecord> {
return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record }))
return this.mutate(args.sessionId, (record) =>
evictAgentSessionOwner({ ...args, record, journalSettlement: 'required' })
)
}
async transitionHandoff(
@@ -17,6 +17,9 @@ export function adjudicateRestartedAgentSessionHandoff(
if (adjudication.disposition === 'readopt') {
return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now })
}
if (adjudication.disposition === 'settlement-pending') {
return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now })
}
if (adjudication.disposition === 'free') {
return updateLease(record, {
...record.lease,
@@ -8,6 +8,7 @@
import {
adjudicateAgentSessionRestart,
agentSessionRestartEvictionSettlementId,
type AgentSessionOwnerProbe
} from '../../shared/agent-session-lease-adjudication'
import type {
@@ -50,6 +51,9 @@ export function applyAgentSessionRestartAdjudication(args: {
// Why: re-adoption is not a new generation, so the fence does not move.
return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now })
}
if (adjudication.disposition === 'settlement-pending') {
return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now })
}
if (adjudication.disposition === 'free') {
// Why: an already-free lease that reloads into `recovering` is unopenable forever; clearing
// the stage restores it without moving the fence or touching the recorded death evidence.
@@ -74,7 +78,9 @@ export function applyAgentSessionRestartAdjudication(args: {
unreconciled: false,
lastRenewedAt: args.now,
handoffOperationId: null,
deathEvidence: adjudication.evidence
deathEvidence: adjudication.evidence,
settlementRetryRequired: true,
settlementRetryId: agentSessionRestartEvictionSettlementId(record.lease, adjudication)
})
}
const stage: AgentSessionHandoffStage =
@@ -391,6 +391,7 @@ beforeEach(async () => {
}
const runtime = {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({
...ensureParams(1),
@@ -180,7 +180,9 @@ function createFixture(
getRuntimeId: () => 'test-runtime',
listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot),
getClientSettings: () => ({
experimentalStructuredNativeChat: options.structuredNativeChatEnabled === true
// Why: defaults on, so a fixture that says nothing about the setting exercises capability
// gating alone; callers opt into the off case explicitly.
experimentalStructuredNativeChat: options.structuredNativeChatEnabled !== false
}),
...calls
} as unknown as OrcaRuntimeService
@@ -87,7 +87,9 @@ describe('projectSessionTabAgentStatus', () => {
}
]
}
const oldClient = projectSessionTabAgentStatus(snapshot, 'mobile', [])
// A paired client that never negotiated the capability, with the setting on: mobile keeps an
// unrenderable row under a fallback title, so only a non-mobile old client still loses them.
const oldClient = projectSessionTabAgentStatus(snapshot, 'runtime', [], true)
expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal'])
expect(oldClient.activeTabId).toBe('tab-1::leaf-1')
expect(oldClient.activeTabType).toBe('terminal')
@@ -96,11 +98,6 @@ describe('projectSessionTabAgentStatus', () => {
expect(oldClient.tabGroups).toHaveLength(1)
expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' })
expect(
projectSessionTabAgentStatus(snapshot, 'mobile', [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
).toEqual(oldClient)
expect(
projectSessionTabAgentStatus(
snapshot,
@@ -118,10 +115,25 @@ describe('projectSessionTabAgentStatus', () => {
)
expect(capableMobile).toBe(snapshot)
const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
const capable = projectSessionTabAgentStatus(
snapshot,
'runtime',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
true
)
expect(capable).toBe(snapshot)
// The host setting is policy for every caller, so a capable desktop client with the
// setting off sees the same projection an old client does.
expect(
projectSessionTabAgentStatus(
snapshot,
'runtime',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
false
)
).toEqual(oldClient)
expect(projectSessionTabAgentStatus(snapshot, undefined, undefined, false)).toEqual(oldClient)
})
const claudeSnapshot = {
@@ -276,8 +288,10 @@ describe('projectSessionTabAgentStatus', () => {
)
it('keeps Claude rows on the local renderer, which negotiates nothing', () => {
expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined)).toBe(claudeSnapshot)
expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [])).toBe(claudeSnapshot)
expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined, true)).toBe(
claudeSnapshot
)
expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [], true)).toBe(claudeSnapshot)
})
it('leaves Codex rows untouched whether or not the Claude capability is present', () => {
@@ -295,11 +309,11 @@ describe('projectSessionTabAgentStatus', () => {
)
}
}
expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined)).toBe(codexOnly)
expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined, true)).toBe(codexOnly)
})
it('withholds session boundaries from legacy paired clients', () => {
const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [])
const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [], true)
expect(projected.tabs[0]).not.toHaveProperty('agentStatus')
})
@@ -308,7 +322,12 @@ describe('projectSessionTabAgentStatus', () => {
const snapshot = makeSnapshot(true)
expect(
projectSessionTabAgentStatus(snapshot, 'runtime', [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY])
projectSessionTabAgentStatus(
snapshot,
'runtime',
[AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY],
true
)
).toBe(snapshot)
})
@@ -317,8 +336,12 @@ describe('projectSessionTabAgentStatus', () => {
const mobileBoundary = makeSnapshot(true)
const runtimeCompletion = makeSnapshot(false)
expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined)).toBe(localBoundary)
expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [])).toBe(mobileBoundary)
expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [])).toBe(runtimeCompletion)
expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined, true)).toBe(
localBoundary
)
expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [], true)).toBe(mobileBoundary)
expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [], true)).toBe(
runtimeCompletion
)
})
})
@@ -55,7 +55,7 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
payload: TPayload,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: readonly RuntimeCapability[] | undefined,
structuredNativeChatEnabled?: boolean
structuredNativeChatEnabled: boolean
): TPayload {
const structuredVisible = structuredNativeChatProjectionEnabled({
clientKind,
@@ -21,9 +21,7 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
raw,
context.clientKind,
context.clientCapabilities,
context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
isStructuredNativeChatEnabled(context.runtime)
)
assertProjectedSessionTabVisible(visible, params.tabId)
assertAgentSessionTabDestructiveMutationSupported(
@@ -100,9 +98,7 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
raw,
context.clientKind,
context.clientCapabilities,
context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
isStructuredNativeChatEnabled(context.runtime)
)
assertProjectedSessionTabVisible(visible, params.tabId)
assertAgentSessionTabDestructiveMutationSupported(
@@ -19,7 +19,7 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
assertProjectedSessionTabVisible(visible, params.tabId)
}
@@ -42,7 +42,7 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
result,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
}
}),
@@ -57,7 +57,7 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
raw,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
translated = translateProjectedSessionTabMove(raw, projected, params)
}
@@ -142,7 +142,7 @@ async function assertVisibleMutationTab(
await runtime.listMobileSessionTabs(worktree, pairedDeviceId),
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
assertProjectedSessionTabVisible(visible, tabId)
}
@@ -28,7 +28,7 @@ export function projectSessionTabsForClient(
snapshot: RuntimeMobileSessionTabsResult,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2],
structuredNativeChatEnabled?: boolean
structuredNativeChatEnabled: boolean
): RuntimeMobileSessionTabsResult {
return projectSessionTabBrowserPlacements(
projectSessionTabAgentStatus(
@@ -41,12 +41,6 @@ export function projectSessionTabsForClient(
)
}
function structuredNativeChatEnabledForContext(context: RpcContext): boolean | undefined {
return context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
}
function projectInventory(
inventory: SessionTabsInventory,
context: RpcContext
@@ -57,7 +51,7 @@ function projectInventory(
snapshot,
context.clientKind,
context.clientCapabilities,
structuredNativeChatEnabledForContext(context)
isStructuredNativeChatEnabled(context.runtime)
)
),
...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context)
@@ -128,7 +122,7 @@ export async function subscribeSessionTabsInventory(
snapshot,
context.clientKind,
context.clientCapabilities,
structuredNativeChatEnabledForContext(context)
isStructuredNativeChatEnabled(context.runtime)
) as SessionTabsChange
const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => {
if (snapshot.navigationIntent === undefined) {
@@ -0,0 +1,23 @@
export function visibleSnapshot() {
return {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::leaf-1',
activeTabType: 'terminal' as const,
tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }],
tabs: [
{
type: 'terminal' as const,
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready' as const,
terminal: 'pty-1',
isActive: true
}
]
}
}
@@ -1,22 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, type Mock } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { SESSION_TAB_METHODS } from './session-tabs'
import { visibleSnapshot } from './session-tabs-snapshot.test-fixture'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
function makeRuntime(experimentalStructuredNativeChat: boolean): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
}
describe('structured session tab restoration follows one rule for every caller', () => {
it('does not restore for the desktop renderer while the host setting is off', async () => {
const runtime = makeRuntime(false)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
{
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(response.ok).toBe(true)
expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled()
})
it('restores for the desktop renderer once the host setting is on', async () => {
const runtime = makeRuntime(true)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
{
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(response.ok).toBe(true)
expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1)
})
it('restores for an in-process caller on the same setting that admits remote clients', async () => {
const restoreCallsBySetting = new Map<boolean, number>()
for (const enabled of [false, true]) {
const runtime = makeRuntime(enabled)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatch(makeRequest('session.tabs.list', { worktree: 'id:wt-1' }))
restoreCallsBySetting.set(
enabled,
(runtime.restoreStructuredAgentSessionTabs as unknown as Mock).mock.calls.length
)
}
expect(restoreCallsBySetting.get(false)).toBe(0)
expect(restoreCallsBySetting.get(true)).toBe(1)
})
})
describe('session tab structured restore gating', () => {
it('does not restore structured tabs for mobile while the host setting is off', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const runtime = makeRuntime(false)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
@@ -34,12 +91,7 @@ describe('session tab structured restore gating', () => {
// Why: an old build has no capability to advertise, and skipping the restore left it with
// nothing to project after a desktop restart — neither the chat nor its fallback row.
it('restores structured tabs for a mobile client that advertises no capability', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const runtime = makeRuntime(true)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
@@ -52,12 +104,7 @@ describe('session tab structured restore gating', () => {
})
it('restores structured tabs for mobile once the setting is present', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const runtime = makeRuntime(true)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
@@ -72,27 +119,3 @@ describe('session tab structured restore gating', () => {
expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1)
})
})
function visibleSnapshot() {
return {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::leaf-1',
activeTabType: 'terminal' as const,
tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }],
tabs: [
{
type: 'terminal' as const,
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready' as const,
terminal: 'pty-1',
isActive: true
}
]
}
}
@@ -4,6 +4,7 @@ import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { SESSION_TAB_METHODS } from './session-tabs'
import { visibleSnapshot } from './session-tabs-snapshot.test-fixture'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
@@ -816,27 +817,3 @@ describe('session tab RPC methods', () => {
)
})
})
function visibleSnapshot() {
return {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::leaf-1',
activeTabType: 'terminal' as const,
tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }],
tabs: [
{
type: 'terminal' as const,
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready' as const,
terminal: 'pty-1',
isActive: true
}
]
}
}
+3 -3
View File
@@ -28,7 +28,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
}
}),
@@ -121,7 +121,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
initial,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
})
initialized = true
@@ -137,7 +137,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
snapshot,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
})
}
@@ -0,0 +1,112 @@
// Admission can be revoked while sessions are still open: the host setting is turned off with a
// chat already on screen. What the caller may still do to that chat is the rule this suite pins.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
ADMISSION_METHODS,
CLEANUP_METHODS
} from './structured-agent-session-gate-classification.test-fixture'
import {
call,
clearStructuredHostStub,
envelope,
hostCalls,
installStructuredHostStub,
SESSION,
STRUCTURED_CLIENT
} from './structured-agent-session-rpc.test-fixture'
beforeEach(() => {
installStructuredHostStub()
})
afterEach(() => {
clearStructuredHostStub()
})
describe('admission revoked while a session is still open', () => {
// The host setting is admission control. Turning it off must not strand a chat that was opened
// while it was on: the pane is still mounted, so its close has to land.
const SETTING_OFF = { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) }
it.each(CLEANUP_METHODS)(
'still serves $method after the host setting is turned off',
async ({ method, params, hostCall }) => {
const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF)
expect(response).toMatchObject({ ok: true })
// `unsubscribe` retires runtime-owned subscriptions rather than calling the host, so its
// result payload is the observable effect.
if (hostCall === 'unsubscribe') {
expect(response).toMatchObject({ result: { unsubscribed: true } })
} else {
expect(hostCalls[hostCall]).toHaveBeenCalled()
}
}
)
it('stops the provider child when closing a chat the setting no longer admits', async () => {
const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT, {
...SETTING_OFF
})
expect(response).toMatchObject({ ok: true, result: { ok: true } })
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
// The durable tab has to be retired too, or the chat comes back on the next sync.
expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false)
})
it('cancels an in-flight turn the setting no longer admits', async () => {
const response = await call(
'agentSession.cancel',
{ envelope: envelope(), turnId: 'turn-1' },
STRUCTURED_CLIENT,
SETTING_OFF
)
expect(response).toMatchObject({ ok: true })
expect(hostCalls.cancel).toHaveBeenCalledOnce()
})
it.each(['runtime', 'mobile'] as const)(
'lets a %s client close a chat it already owns',
async (clientKind) => {
const response = await call(
'agentSession.close',
{ sessionId: SESSION },
{ clientKind, clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
SETTING_OFF
)
expect(response).toMatchObject({ ok: true })
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
}
)
it('lets an in-process caller close, which is how terminal disposal retires a chat', async () => {
const response = await call(
'agentSession.close',
{ sessionId: SESSION },
undefined,
SETTING_OFF
)
expect(response).toMatchObject({ ok: true })
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
})
it.each(ADMISSION_METHODS)(
'keeps $method refused once the setting is off',
async ({ method, params }) => {
const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF)
// Asserting the gate's own code, not merely `ok: false`: a params-validation failure would
// pass a bare falsy check and hide a gate that had stopped refusing.
expect(response).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
}
)
})
@@ -0,0 +1,79 @@
// The method-to-gate classification from `structured-agent-session-gate.ts`, as a table the
// suites iterate. Adding an `agentSession.*` method means adding it to exactly one of these.
import {
attachParams,
envelope,
sendParams,
SESSION
} from './structured-agent-session-rpc.test-fixture'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
/** Stops or retires work the caller already owns, so admission may already have been revoked. */
export const CLEANUP_METHODS = [
{
method: 'agentSession.close',
params: { sessionId: SESSION },
hostCall: 'close'
},
{
method: 'agentSession.cancel',
params: { envelope: envelope(), turnId: 'turn-1' },
hostCall: 'cancel'
},
{
method: 'agentSession.release',
params: { sessionId: SESSION, holderId: 'surface-1' },
hostCall: 'release'
},
{
method: 'agentSession.unsubscribe',
params: { sessionId: SESSION },
hostCall: 'unsubscribe'
}
] as const
/** Starts, extends, retains or reads work, so every one stays refused once the setting is off. */
export const ADMISSION_METHODS = [
{ method: 'agentSession.createSupport', params: { worktree: 'id:workspace-1', agent: 'codex' } },
{
method: 'agentSession.create',
params: {
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields: { worktree: 'id:workspace-1', agent: 'codex' }
})
}),
worktree: 'id:workspace-1',
agent: 'codex'
}
},
{ method: 'agentSession.ensure', params: attachParams() },
{ method: 'agentSession.send', params: sendParams() },
{
method: 'agentSession.respondToApproval',
params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'allow' }
},
{
method: 'agentSession.respondToQuestion',
params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'yes' }
},
{
method: 'agentSession.setOption',
params: { envelope: envelope(), key: 'model', value: 'gpt-live' }
},
{
method: 'agentSession.requestHandoff',
params: { envelope: envelope(), direction: 'to-tui', mode: 'now' }
},
{ method: 'agentSession.handoffStatus', params: { sessionId: SESSION } },
{ method: 'agentSession.options', params: { sessionId: SESSION } },
{ method: 'agentSession.history', params: { sessionId: SESSION, direction: 'tail' } },
{ method: 'agentSession.subscribe', params: { sessionId: SESSION } },
{ method: 'agentSession.hold', params: { sessionId: SESSION, holderId: 'surface-1' } },
{ method: 'agentSession.reveal', params: { sessionId: SESSION } },
{ method: 'agentSession.subscribeStatus', params: null }
] as const
@@ -12,7 +12,10 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
import type { RpcContext } from '../core'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
import {
supportsStructuredAgentSessionCapability,
supportsStructuredAgentSessions
} from './structured-agent-session-policy'
/**
* In-process callers are the same build as the host, so they carry no negotiated
@@ -37,6 +40,39 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo
return host
}
/**
* WHICH GATE DOES A NEW `agentSession.*` METHOD GET?
*
* The host setting is admission control, and admission can be revoked while sessions are still
* open. So the surface splits by what a method does to work in flight, not by how dangerous it
* sounds:
*
* - Starts, extends, retains or reads work -> `requireStructuredHost`. Revoked admission means
* no new turns, no new holds, no new reads. create, send, ensure, setOption, requestHandoff,
* subscribe, hold, reveal, history, options and the status stream all live here.
* - Stops or retires work the caller already owns -> `requireStructuredCleanupHost`. close,
* cancel, unsubscribe and release live here.
*
* Cleanup keeps working after the setting is turned off because the alternative strands the user:
* a session opened while the setting was on stays open, and refusing its close leaves a chat with
* a live provider child that its own owner can no longer shut down. Stopping is never the thing
* the policy exists to prevent.
*
* Cleanup is not an escape hatch. It still demands the negotiated wire capability, so a client
* that never advertised the surface still cannot see it, and it never creates a host — it can
* only retire what already exists.
*/
export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost {
if (!supportsStructuredAgentSessionCapability(ctx)) {
throw new Error('structured_agent_session_unsupported')
}
const host = getStructuredAgentSessionHost()
if (!host) {
throw new Error('structured_agent_session_unsupported')
}
return host
}
/** Builds the host for the calls that address a session by durable record rather than by live
* state: attach, which is the only way a session comes into being, plus hold and reveal, which
* each reach for a record on disk this process may not have opened yet. Every other method
@@ -41,6 +41,7 @@ let runtime: OrcaRuntimeService
let dispatcher: RpcDispatcher
let closeSession: Mock<NonNullable<StructuredAgentSessionAdapter['closeSession']>>
let requests = 0
let structuredNativeChatEnabled = true
async function call(method: string, params: unknown): Promise<RpcResponse> {
const replies: RpcResponse[] = []
@@ -57,6 +58,7 @@ beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-'))
resetHostTestOperationIds()
requests = 0
structuredNativeChatEnabled = true
closeSession = vi.fn(async () => true)
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
host = new StructuredAgentSessionHost({
@@ -86,6 +88,13 @@ beforeEach(async () => {
})
setStructuredAgentSessionHost(host)
runtime = new OrcaRuntimeService()
// The structured surface is settings-gated for every caller, in-process included.
vi.spyOn(runtime, 'getClientSettings').mockImplementation(
() =>
({ experimentalStructuredNativeChat: structuredNativeChatEnabled }) as ReturnType<
OrcaRuntimeService['getClientSettings']
>
)
dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS })
expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({
ok: true
@@ -121,6 +130,23 @@ describe('a client that holds a session', () => {
expect(closeSession).toHaveBeenCalledWith(SESSION)
})
it('releases its hold and cleanup after the setting is disabled', async () => {
const release = vi.spyOn(host, 'release')
await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' })
structuredNativeChatEnabled = false
expect(
await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' })
).toMatchObject({ ok: true })
const releaseCallsAfterRpc = release.mock.calls.length
runtime.cleanupSubscriptionsForConnection(CONNECTION)
expect(releaseCallsAfterRpc).toBe(2)
expect(release).toHaveBeenCalledTimes(releaseCallsAfterRpc)
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
expect(closeSession).toHaveBeenCalledWith(SESSION)
})
it('does not report success when no provider child can be acquired', async () => {
const response = await call('agentSession.hold', {
sessionId: 'session-missing',
@@ -213,6 +239,31 @@ describe('a client that disappears without cleanup', () => {
expect(closeSession).toHaveBeenCalledWith(SESSION)
})
it('unsubscribes and releases stream retention after the setting is disabled', async () => {
await dispatcher.dispatchStreaming(
{
id: 'stream-disabled-cleanup',
authToken: 'token',
method: 'agentSession.subscribe',
params: { sessionId: SESSION }
},
() => {},
CLIENT
)
expect(host.isHeld(SESSION)).toBe(true)
structuredNativeChatEnabled = false
expect(
await call('agentSession.unsubscribe', {
sessionId: SESSION,
subscriptionId: 'stream-disabled-cleanup'
})
).toMatchObject({ ok: true })
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
expect(closeSession).toHaveBeenCalledWith(SESSION)
})
it('does not let a stream alone resume a released session', async () => {
await host.close(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
@@ -12,6 +12,7 @@
import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core'
import {
ensureStructuredHostInstalled,
requireStructuredCleanupHost,
requireStructuredHost
} from './structured-agent-session-gate'
import { HoldParams } from './structured-agent-session-schemas'
@@ -53,7 +54,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS: RpcAnyMethod[] = [
name: 'agentSession.release',
params: HoldParams,
handler: async (params, ctx) => {
const host = requireStructuredHost(ctx)
const host = requireStructuredCleanupHost(ctx)
const holderKey = holderKeyFor(ctx, params.holderId)
host.release(params.sessionId, holderKey)
// Retires the backstop too; its release is a no-op against a holder already gone.
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
function runtimeWithSetting(
experimentalStructuredNativeChat: boolean
): Pick<OrcaRuntimeService, 'getClientSettings'> {
return {
getClientSettings: () => ({ experimentalStructuredNativeChat })
} as unknown as Pick<OrcaRuntimeService, 'getClientSettings'>
}
const CAPABLE = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
/** Every caller shape that reaches the policy: desktop renderer, paired phone, in-process. */
const CALLERS = [
{ name: 'desktop renderer', clientKind: 'runtime' as const, clientCapabilities: CAPABLE },
{ name: 'paired mobile', clientKind: 'mobile' as const, clientCapabilities: CAPABLE },
{ name: 'in-process', clientKind: undefined, clientCapabilities: undefined }
]
describe('supportsStructuredAgentSessions', () => {
it.each([true, false])('admits every caller alike when the setting is %s', (enabled) => {
const decisions = CALLERS.map((caller) =>
supportsStructuredAgentSessions({
clientKind: caller.clientKind,
clientCapabilities: caller.clientCapabilities,
runtime: runtimeWithSetting(enabled)
})
)
expect(decisions).toEqual([enabled, enabled, enabled])
})
it('admits a capability-less in-process caller, which negotiates nothing', () => {
expect(
supportsStructuredAgentSessions({
clientKind: undefined,
clientCapabilities: undefined,
runtime: runtimeWithSetting(true)
})
).toBe(true)
})
it('still refuses a remote client that did not advertise the capability', () => {
for (const clientKind of ['runtime', 'mobile'] as const) {
expect(
supportsStructuredAgentSessions({
clientKind,
clientCapabilities: [],
runtime: runtimeWithSetting(true)
})
).toBe(false)
}
})
it('leaves desktop launch admission unchanged, because launches require the setting anyway', () => {
// `agent-launch-routing.ts` refuses to route a structured launch unless
// `experimentalStructuredNativeChat` is on, so the only state a desktop launch can
// reach the host in is setting-on — which admits exactly as it did before.
expect(
supportsStructuredAgentSessions({
clientKind: 'runtime',
clientCapabilities: CAPABLE,
runtime: runtimeWithSetting(true)
})
).toBe(true)
})
it('reads the setting from the caller-supplied value when no runtime is available', () => {
expect(
supportsStructuredAgentSessions({
clientKind: 'runtime',
clientCapabilities: CAPABLE,
structuredNativeChatEnabled: true
})
).toBe(true)
expect(
supportsStructuredAgentSessions({
clientKind: 'runtime',
clientCapabilities: CAPABLE,
structuredNativeChatEnabled: false
})
).toBe(false)
})
it('treats an unreadable settings store as off rather than admitting', () => {
expect(
supportsStructuredAgentSessions({
clientKind: 'runtime',
clientCapabilities: CAPABLE,
runtime: {
getClientSettings: () => {
throw new Error('settings unavailable')
}
} as unknown as Pick<OrcaRuntimeService, 'getClientSettings'>
})
).toBe(false)
})
})
@@ -20,18 +20,24 @@ export function isStructuredNativeChatEnabled(
}
}
export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean {
if (context.clientKind === undefined) {
return true
}
const hasCapability =
export function supportsStructuredAgentSessionCapability(
context: Pick<StructuredPolicyContext, 'clientCapabilities' | 'clientKind'>
): boolean {
return (
context.clientKind === undefined ||
context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true
if (!hasCapability) {
)
}
/**
* One rule for every caller. The host setting is policy and applies to desktop, mobile and
* in-process callers alike; the negotiated capability is a wire term, so it is asked of remote
* clients only — in-process callers are the same build as the host and never negotiate one.
*/
export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean {
if (!supportsStructuredAgentSessionCapability(context)) {
return false
}
if (context.clientKind !== 'mobile') {
return true
}
return (
context.structuredNativeChatEnabled === true ||
(context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false)
@@ -41,7 +47,8 @@ export function supportsStructuredAgentSessions(context: StructuredPolicyContext
export function structuredNativeChatProjectionEnabled(args: {
clientKind: 'mobile' | 'runtime' | undefined
clientCapabilities: readonly RuntimeCapability[] | undefined
structuredNativeChatEnabled?: boolean
// Required so no call site can silently project as if the host setting were off.
structuredNativeChatEnabled: boolean
}): boolean {
return supportsStructuredAgentSessions(args)
}
@@ -71,6 +71,9 @@ async function create(
): Promise<RpcResponse> {
const runtime = {
getRuntimeId: () => 'runtime-1',
// The structured surface is settings-gated for every caller; these fixtures probe the
// pre-commit boundary, which only runs once the gate admits the call.
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
@@ -0,0 +1,270 @@
// The `agentSession.*` dispatcher harness, shared by the suites that exercise the wire
// boundary. `hostCalls` and `runtimeCalls` keep one identity for the process and are
// repopulated per test, so a suite can read `hostCalls.close` without re-importing it.
import { vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import {
StructuredAgentSessionStatusFeed,
type StructuredAgentSessionStatusSubscriber
} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
export const SESSION = 'session-alpha'
export const FINGERPRINT = 'f'.repeat(64)
export const OPERATION = '1800000000000-00000000000000000000000000000001'
export function envelope(overrides: Record<string, unknown> = {}) {
return {
sessionId: SESSION,
clientOperationId: OPERATION,
expectedRuntimeFence: 1,
payloadFingerprint: FINGERPRINT,
...overrides
}
}
export function sendParams(overrides: Record<string, unknown> = {}) {
return {
envelope: envelope(),
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
...overrides
}
}
export function attachParams(overrides: Record<string, unknown> = {}) {
return {
envelope: envelope({ expectedRuntimeFence: null }),
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
runtimeKind: 'native',
providerHandle: { kind: 'codex', threadId: 'thread-1' },
...overrides
}
}
function request(method: string, params: unknown): RpcRequest {
return { id: 'request-1', authToken: 'token', method, params }
}
export const hostCalls: Record<string, ReturnType<typeof vi.fn>> = {}
export const runtimeCalls: Record<string, ReturnType<typeof vi.fn>> = {}
function reset(record: Record<string, ReturnType<typeof vi.fn>>): void {
for (const key of Object.keys(record)) {
delete record[key]
}
}
export const STATUS_SESSION = 'session-status'
export const STATUS_ITEMS: AgentJournalRenderItem[] = [
{
itemId: 'user-1',
sequence: 1,
revision: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }
},
{
itemId: 'turn-1',
sequence: 2,
revision: 1,
observedAt: 2,
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }
}
]
/** One indexed session over a journal that reads back fixed items; the projection is real. */
function statusFeed(): StructuredAgentSessionStatusFeed {
return new StructuredAgentSessionStatusFeed({
sessions: new Map([
[
STATUS_SESSION,
{
journal: {
isReadOnly: false,
lastActivityAt: () => 2,
snapshot: () => ({ items: STATUS_ITEMS })
} as unknown as AgentSessionJournal,
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
}
]
]),
getRecord: () => null,
now: () => 1_000
})
}
export function hostStub(): StructuredAgentSessionHost {
reset(hostCalls)
Object.assign(hostCalls, {
attach: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
sessionId: SESSION,
fence: 1,
page: {
sessionId: SESSION,
epoch: 'epoch-a',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: {
oldest: null,
newest: null,
nextCursor: { epoch: 'epoch-a', sequence: 0 }
},
liveCursor: { epoch: 'epoch-a', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
})),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
close: vi.fn(async () => undefined),
revealSession: vi.fn(async () => ({
sessionId: SESSION,
workspaceId: 'workspace-1',
agent: 'codex' as const,
readable: true
})),
setSessionTabVisibility: vi.fn(async () => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
status: {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
}
}
})),
supportsCreate: vi.fn(() => true),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live' }
})),
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
subscribe: vi.fn(() => () => undefined),
// A real feed, so the snapshot this method hands back is a genuine projection rather
// than a shape the stub restated.
subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) =>
statusFeed().subscribe(subscriber)
),
unsubscribe: vi.fn(),
release: vi.fn()
})
return hostCalls as unknown as StructuredAgentSessionHost
}
export function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatcher {
reset(runtimeCalls)
Object.assign(runtimeCalls, {
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
envelope: params.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: params.agent,
agent: params.agent,
accountHome: {
variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex'
},
options:
params.agent === 'claude'
? { model: 'opus', effort: 'high' }
: { model: 'gpt-5.6-sol', effort: 'medium' },
runtimeKind: 'native'
})),
publishStructuredAgentSessionTab: vi.fn()
})
const runtime = {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
...runtimeCalls,
...runtimeOverrides
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: STRUCTURED_AGENT_SESSION_METHODS
})
}
/** The reply path is the only one that carries a client's negotiated identity,
* which is exactly what the capability gate reads. */
export async function call(
method: string,
params: unknown,
client?: {
clientId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: string[]
},
runtimeOverrides: Record<string, unknown> = {}
): Promise<RpcResponse> {
const replies: RpcResponse[] = []
await dispatcher(runtimeOverrides).dispatchStreaming(
request(method, params),
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
client
)
const first = replies[0]
if (!first) {
throw new Error(`no reply for ${method}`)
}
return first
}
export const STRUCTURED_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
export const STRUCTURED_MOBILE_CLIENT = {
clientKind: 'mobile' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
/** Every suite wants the same lifecycle: a fresh stub per test, no host left installed. */
export function installStructuredHostStub(): void {
setStructuredAgentSessionHost(hostStub())
}
export function clearStructuredHostStub(): void {
setStructuredAgentSessionHost(null)
}
@@ -1,15 +1,8 @@
// The wire boundary: who may see `agentSession.*` at all, and what shapes it
// accepts once they can.
// accepts once they can. The dispatcher harness lives in the shared fixture.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import {
StructuredAgentSessionStatusFeed,
type StructuredAgentSessionStatusSubscriber
} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed'
import {
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION,
@@ -17,252 +10,31 @@ import {
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
import { ALL_RPC_METHODS } from './index'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
const SESSION = 'session-alpha'
const FINGERPRINT = 'f'.repeat(64)
const OPERATION = '1800000000000-00000000000000000000000000000001'
function envelope(overrides: Record<string, unknown> = {}) {
return {
sessionId: SESSION,
clientOperationId: OPERATION,
expectedRuntimeFence: 1,
payloadFingerprint: FINGERPRINT,
...overrides
}
}
function sendParams(overrides: Record<string, unknown> = {}) {
return {
envelope: envelope(),
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
...overrides
}
}
function attachParams(overrides: Record<string, unknown> = {}) {
return {
envelope: envelope({ expectedRuntimeFence: null }),
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
runtimeKind: 'native',
providerHandle: { kind: 'codex', threadId: 'thread-1' },
...overrides
}
}
function request(method: string, params: unknown): RpcRequest {
return { id: 'request-1', authToken: 'token', method, params }
}
let hostCalls: Record<string, ReturnType<typeof vi.fn>>
let runtimeCalls: Record<string, ReturnType<typeof vi.fn>>
const STATUS_SESSION = 'session-status'
const STATUS_ITEMS: AgentJournalRenderItem[] = [
{
itemId: 'user-1',
sequence: 1,
revision: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }
},
{
itemId: 'turn-1',
sequence: 2,
revision: 1,
observedAt: 2,
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }
}
]
/** One indexed session over a journal that reads back fixed items; the projection is real. */
function statusFeed(): StructuredAgentSessionStatusFeed {
return new StructuredAgentSessionStatusFeed({
sessions: new Map([
[
STATUS_SESSION,
{
journal: {
isReadOnly: false,
lastActivityAt: () => 2,
snapshot: () => ({ items: STATUS_ITEMS })
} as unknown as AgentSessionJournal,
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
}
]
]),
getRecord: () => null,
now: () => 1_000
})
}
function hostStub(): StructuredAgentSessionHost {
hostCalls = {
attach: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
sessionId: SESSION,
fence: 1,
page: {
sessionId: SESSION,
epoch: 'epoch-a',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: {
oldest: null,
newest: null,
nextCursor: { epoch: 'epoch-a', sequence: 0 }
},
liveCursor: { epoch: 'epoch-a', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
})),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
close: vi.fn(async () => undefined),
revealSession: vi.fn(async () => ({
sessionId: SESSION,
workspaceId: 'workspace-1',
agent: 'codex' as const,
readable: true
})),
setSessionTabVisibility: vi.fn(async () => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
status: {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
}
}
})),
supportsCreate: vi.fn(() => true),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live' }
})),
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
subscribe: vi.fn(() => () => undefined),
// A real feed, so the snapshot this method hands back is a genuine projection rather
// than a shape the stub restated.
subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) =>
statusFeed().subscribe(subscriber)
),
unsubscribe: vi.fn()
}
return hostCalls as unknown as StructuredAgentSessionHost
}
function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatcher {
runtimeCalls = {
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
envelope: params.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: params.agent,
agent: params.agent,
accountHome: {
variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex'
},
options:
params.agent === 'claude'
? { model: 'opus', effort: 'high' }
: { model: 'gpt-5.6-sol', effort: 'medium' },
runtimeKind: 'native'
})),
publishStructuredAgentSessionTab: vi.fn()
}
const runtime = {
getRuntimeId: () => 'runtime-1',
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
...runtimeCalls,
...runtimeOverrides
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: STRUCTURED_AGENT_SESSION_METHODS
})
}
/** The reply path is the only one that carries a client's negotiated identity,
* which is exactly what the capability gate reads. */
async function call(
method: string,
params: unknown,
client?: {
clientId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: string[]
},
runtimeOverrides: Record<string, unknown> = {}
): Promise<RpcResponse> {
const replies: RpcResponse[] = []
await dispatcher(runtimeOverrides).dispatchStreaming(
request(method, params),
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
client
)
const first = replies[0]
if (!first) {
throw new Error(`no reply for ${method}`)
}
return first
}
const STRUCTURED_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
const STRUCTURED_MOBILE_CLIENT = {
clientKind: 'mobile' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
import { CLEANUP_METHODS } from './structured-agent-session-gate-classification.test-fixture'
import {
attachParams,
call,
clearStructuredHostStub,
envelope,
hostCalls,
installStructuredHostStub,
runtimeCalls,
SESSION,
sendParams,
STATUS_SESSION,
STRUCTURED_CLIENT,
STRUCTURED_MOBILE_CLIENT
} from './structured-agent-session-rpc.test-fixture'
beforeEach(() => {
setStructuredAgentSessionHost(hostStub())
installStructuredHostStub()
})
afterEach(() => {
setStructuredAgentSessionHost(null)
clearStructuredHostStub()
})
describe('agentSession.reveal', () => {
@@ -454,6 +226,41 @@ describe('capability gating', () => {
expect(hostCalls.send).toHaveBeenCalledTimes(1)
})
it.each(CLEANUP_METHODS)(
'keeps $method hidden from remote clients without the capability',
async ({ method, params, hostCall }) => {
const response = await call(method, params, {
clientKind: 'runtime',
clientCapabilities: []
})
expect(response).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
expect(hostCalls[hostCall]).not.toHaveBeenCalled()
}
)
it.each(CLEANUP_METHODS)(
'does not install a host for cleanup-only method $method',
async ({ method, params }) => {
const ensureHost = vi.fn()
setStructuredAgentSessionHost(null)
const response = await call(method, params, STRUCTURED_CLIENT, {
getClientSettings: () => ({ experimentalStructuredNativeChat: false }),
ensureStructuredAgentSessionHost: ensureHost
})
expect(response).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
expect(ensureHost).not.toHaveBeenCalled()
}
)
it('serves an in-process caller, which negotiates no capabilities at all', async () => {
const response = await call('agentSession.send', sendParams())
expect(response).toMatchObject({ ok: true })
@@ -14,6 +14,7 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext
import {
ensureStructuredHostInstalled as ensureHostInstalled,
requireStructuredCapability,
requireStructuredCleanupHost,
requireStructuredHost as requireHost,
structuredCallerFor as callerFor,
supportsStructuredSessions
@@ -178,9 +179,10 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params)
}),
defineMethod({
// Stopping a turn, so it stays available after admission is revoked: see the gate's rule.
name: 'agentSession.cancel',
params: CancelParams,
handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params)
handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params)
}),
defineMethod({
// Releasing a chat view, not ending a conversation: the record and journal stay on disk so the
@@ -188,7 +190,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
name: 'agentSession.close',
params: OptionsParams,
handler: async (params, ctx) => {
const host = requireHost(ctx)
// Cleanup gate: turning the host setting off must not strand an open chat whose owner can
// then never close it. See the rule on `requireStructuredCleanupHost`.
const host = requireStructuredCleanupHost(ctx)
// Terminal-disposal closes use this RPC without the session-tabs retirement RPC.
if (typeof host.setSessionTabVisibility === 'function') {
await host.setSessionTabVisibility(params.sessionId, false)
@@ -284,7 +288,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
name: 'agentSession.unsubscribe',
params: UnsubscribeParams,
handler: async (params, ctx) => {
requireHost(ctx)
// Why: cleanup must stay available after the setting is disabled, so an admitted caller can
// retire resources it already owns; the base still comes from main's shared helper.
requireStructuredCleanupHost(ctx)
const base = subscriptionBaseFor(ctx, params.sessionId)
if (params.subscriptionId) {
ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`)
@@ -242,6 +242,7 @@ beforeEach(async () => {
configuredCodexProfile = 'configured'
const runtime = {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
resolveStructuredAgentSessionCreateIntent: async () => {
const {
@@ -290,6 +290,7 @@ beforeEach(async () => {
configuredCodexProfile = 'configured'
const runtime = {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
resolveStructuredAgentSessionCreateIntent: async () => {
const {
+1 -1
View File
@@ -293,7 +293,7 @@ function appendNormalizedToMultilineTailBuffer(
const line = rewritten[index]!
const lastChar = line.charCodeAt(line.length - 1)
if (lastChar === 32 || lastChar === 9) {
rewritten[index] = line.replace(/[ \t]+$/g, '')
rewritten[index] = trimTerminalLineRight(line)
}
}
for (const line of windowed.lines) {
@@ -185,7 +185,7 @@ function finalizeRetainedTerminalRows(
newlyCompletedLines: string[]
} {
let truncated = initialTruncated
let retainedRows = rows.map((row) => ({ ...row, text: row.text.replace(/[ \t]+$/g, '') }))
let retainedRows = rows.map((row) => ({ ...row, text: trimTerminalLineRight(row.text) }))
if (retainedRows.length > MAX_TAIL_LINES + 1) {
const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1)
@@ -0,0 +1,32 @@
import { performance } from 'node:perf_hooks'
import { describe, expect, it } from 'vitest'
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
import { trimTerminalLineRight } from './terminal-tail-line-controls'
describe('terminal redraw whitespace', () => {
it.each([
['hello \t', 'hello'],
[' \thello \t world \t', ' \thello \t world'],
[' \t', ''],
['hello\u00a0 \t', 'hello\u00a0'],
['hello\n', 'hello\n']
])('preserves terminal text while trimming spaces and tabs: %j', (input, expected) => {
expect(trimTerminalLineRight(input)).toBe(expected)
})
it.each([2, 20])('handles padded redraws across %i retained rows without stalling', (rows) => {
const padded = `${' '.repeat(32_000)}marker \t`
const previousLines = Array.from({ length: rows }, (_, index) =>
index === 0 ? padded : `row ${index}`
)
const start = performance.now()
let result: ReturnType<typeof appendNormalizedToTailBuffer> | undefined
for (let frame = 0; frame < 4; frame += 1) {
result = appendNormalizedToTailBuffer(previousLines, 'footer', '\x1b[1A\rupdated')
}
const elapsedMs = performance.now() - start
expect(result?.lines[0]).toBe(`${' '.repeat(32_000)}marker`)
// Interior padding made the trailing-whitespace regex backtrack quadratically.
expect(elapsedMs).toBeLessThan(500)
})
})
@@ -0,0 +1,65 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
import { resolveGitBashPath } from '../git-bash'
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
import { listPtyJobProcessIds, terminatePtyJob } from './windows-pty-job'
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
function isAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM'
}
}
describeOnWindows('MSYS terminal job ownership', () => {
it('retains and terminates a child across Git Bash shell replacement', async () => {
const shell = resolveGitBashPath()
expect(shell, 'Git for Windows must be installed on the native test runner').not.toBeNull()
const directory = mkdtempSync(join(tmpdir(), 'orca-msys-job-'))
const script = join(directory, 'owned-child.js')
writeFileSync(
script,
"console.log('MSYS_OWNED_CHILD=' + process.pid); setInterval(() => {}, 1000)\n"
)
const pty = await import('node-pty')
const proc = pty.spawn(shell!, ['-c', 'exec "$BASH" --noprofile --norc -i'], {
cwd: tmpdir(),
cols: 120,
rows: 30,
useConptyDll: true
})
let output = ''
let childPid: number | undefined
proc.onData((chunk) => {
output += chunk
const match = /MSYS_OWNED_CHILD=(\d+)/.exec(output)
if (match) {
childPid = Number(match[1])
}
})
try {
proc.write(
`${quotePosixShell(process.execPath.replace(/\\/g, '/'))} ${quotePosixShell(script.replace(/\\/g, '/'))}\r`
)
await vi.waitFor(() => expect(childPid).toBeDefined(), { timeout: 15_000 })
expect(isAlive(childPid!)).toBe(true)
expect(listPtyJobProcessIds(proc)).toContain(childPid)
expect(terminatePtyJob(proc)).toBe('terminated')
await vi.waitFor(() => expect(isAlive(childPid!)).toBe(false), { timeout: 5_000 })
} finally {
// The failing baseline can leave this exact fixture child outside the job.
if (childPid && isAlive(childPid)) {
process.kill(childPid)
}
proc.kill()
removeTreeSync(directory)
}
}, 30_000)
})
@@ -7,6 +7,7 @@ import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../../store'
import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory'
const storeMocks = vi.hoisted(() => ({
refreshGrokRateLimits: vi.fn(),
@@ -16,14 +17,7 @@ const storeMocks = vi.hoisted(() => ({
}))
const mockStoreState = {
rateLimits: {
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
rateLimits: createEmptyRateLimitState({
grok: {
provider: 'grok',
session: null,
@@ -37,14 +31,8 @@ const mockStoreState = {
error: null,
status: 'ok'
},
minimaxCookieConfigured: false,
minimaxApiKeyConfigured: false,
grokAuthConfigured: true,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
inactiveCodexAccounts: []
},
grokAuthConfigured: true
}),
refreshGrokRateLimits: storeMocks.refreshGrokRateLimits,
openSettingsPage: storeMocks.openSettingsPage,
openSettingsTarget: storeMocks.openSettingsTarget,
@@ -3,6 +3,7 @@ import type {
ProviderRateLimits,
ProviderRateLimitStatus
} from '../../../../shared/rate-limit-types'
import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory'
import {
getVisibleUsageProvider,
hasUsageProviderSettings,
@@ -383,21 +384,7 @@ describe('getVisibleUsageProvider', () => {
describe('isUsageEmptyState', () => {
it('waits for provider snapshots before showing the setup CTA', () => {
expect(
isUsageEmptyState(
{
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null
},
usageSettings()
)
).toBe(false)
expect(isUsageEmptyState(createEmptyRateLimitState(), usageSettings())).toBe(false)
})
it('treats provider keys omitted by an older main process as pending', () => {
@@ -466,21 +453,7 @@ describe('isUsageEmptyState', () => {
})
it('waits for settings before showing the setup CTA', () => {
expect(
isUsageEmptyState(
{
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null
},
null
)
).toBe(false)
expect(isUsageEmptyState(createEmptyRateLimitState(), null)).toBe(false)
})
it('shows the setup CTA for a loaded profile with no configured usage provider', () => {
@@ -0,0 +1,199 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushAsyncTicks, createDeferred, renderHeadlessBuffer } from './pty-connection-test-async'
import {
createMockTransport,
createPane,
createManager,
type ConnectCallbacks,
type MockTransport
} from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
} from './pty-connection-test-environment'
const {
resetAndRefreshAllTerminalWebglAtlases,
scheduleTerminalWebglAtlasRecovery,
scheduleRuntimeGraphSync,
shouldSeedCacheTimerOnInitialTitle,
toastInfo,
notifyCodexPaneBoundForStaleSweep
} = vi.hoisted(() => ({
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
scheduleTerminalWebglAtlasRecovery: vi.fn(),
scheduleRuntimeGraphSync: vi.fn(),
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
toastInfo: vi.fn(),
notifyCodexPaneBoundForStaleSweep: vi.fn()
}))
let mockStoreState: StoreState
let transportFactoryQueue: MockTransport[] = []
let createdTransportOptions: Record<string, unknown>[] = []
let storeSubscribers: ((state: StoreState) => void)[] = []
vi.mock('@/runtime/sync-runtime-graph', () => ({
scheduleRuntimeGraphSync
}))
vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resetAndRefreshAllTerminalWebglAtlases
}))
vi.mock('./terminal-webgl-atlas-recovery', () => ({
scheduleTerminalWebglAtlasRecovery
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mockStoreState,
subscribe: (listener: (state: StoreState) => void) => {
storeSubscribers.push(listener)
return () => {
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
}
}
}
}))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
return buildAgentStatusModuleMock(await importOriginal<Record<string, unknown>>())
})
vi.mock('./cache-timer-seeding', () => ({
shouldSeedCacheTimerOnInitialTitle
}))
vi.mock('sonner', () => ({
toast: {
info: toastInfo
}
}))
vi.mock('@/lib/codex-stale-pane-sweep', () => ({
notifyCodexPaneBoundForStaleSweep
}))
// The connection fixture invokes hooks without mounting React.
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof React>()
return {
...actual,
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
}
})
vi.mock('./pty-transport', () => ({
createIpcPtyTransport: vi.fn((options: Record<string, unknown>) => {
createdTransportOptions.push(options)
const nextTransport = transportFactoryQueue.shift()
if (!nextTransport) {
throw new Error('No mock transport queued')
}
return nextTransport
})
}))
vi.mock('./remote-runtime-pty-transport', () => ({
createRemoteRuntimePtyTransport: vi.fn(
(_environmentId: string, options: Record<string, unknown>) => {
createdTransportOptions.push(options)
const nextTransport = transportFactoryQueue.shift()
if (!nextTransport) {
throw new Error('No mock transport queued')
}
return nextTransport
}
)
}))
// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher.
vi.mock('./pty-dispatcher', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
getEagerPtyBufferHandle: vi.fn(() => undefined)
}
})
const { safeFitAndThen } = vi.hoisted(() => ({ safeFitAndThen: vi.fn() }))
vi.mock('@/lib/pane-manager/pane-tree-ops', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
safeFitAndThen
}))
function createDeps(overrides: Record<string, unknown> = {}) {
return buildPaneConnectionDeps(() => mockStoreState, overrides)
}
describe('connectPanePty', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
transportFactoryQueue = []
createdTransportOptions = []
storeSubscribers = []
mockStoreState = createInitialStoreState(() => mockStoreState)
installTerminalTestGlobals()
})
afterEach(async () => {
await restoreTerminalTestGlobals()
})
it('repaints overflowed live output when the deadline interrupts an answered snapshot', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
let onData: ConnectCallbacks['onData']
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
onData = callbacks.onData
return 'pty-id'
})
transportFactoryQueue.push(transport)
const fit = createDeferred<boolean>()
safeFitAndThen.mockReturnValue({ completion: Promise.resolve(true), cancel: vi.fn() })
const getSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
const hidden = 'hidden-before-flood\r\n'
const overflow = 'v'.repeat(512 * 1024 + 1)
const done = 'HIDDEN_FLOOD_DONE\r\n'
getSnapshot
.mockResolvedValueOnce({ data: hidden, cols: 120, rows: 40, seq: hidden.length })
.mockResolvedValue({ data: done, cols: 120, rows: 40, seq: hidden.length + overflow.length })
const pane = createPane(1)
const deps = createDeps({ isVisibleRef: { current: false }, startup: { command: 'codex' } })
const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(6)
safeFitAndThen.mockClear()
safeFitAndThen.mockReturnValueOnce({
completion: fit.promise,
cancel: vi.fn(() => fit.resolve(false))
})
vi.useFakeTimers()
onData?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
onData?.('v', { seq: hidden.length + 1, rawLength: 1 })
await flushAsyncTicks(30)
expect(safeFitAndThen).toHaveBeenCalledTimes(1)
onData?.(overflow, { seq: hidden.length + overflow.length, rawLength: overflow.length })
await vi.advanceTimersByTimeAsync(750)
fit.resolve(true)
await flushAsyncTicks(30)
await vi.advanceTimersByTimeAsync(2_000)
await flushAsyncTicks(30)
const output = pane.terminal.write.mock.calls.map(([data]) => data).join('')
expect(output).toContain(done)
expect(output).not.toContain('main recovery was unavailable')
expect(getSnapshot).toHaveBeenCalledTimes(2)
disposable.dispose()
vi.useRealTimers()
expect(await renderHeadlessBuffer([output])).toEqual(await renderHeadlessBuffer([done]))
})
})
@@ -129,7 +129,20 @@ export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): vo
) {
return
}
session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
// A fetched snapshot plus live overflow is backpressure, not unavailable recovery. The
// replay paints synchronously before it awaits its fit, so this deadline never lands
// mid-paint: adopt that painted image as the baseline — the overflow abandon below
// returns before arming one, and without it main's ACK backlog repaints as duplicates.
const replayed = session.hiddenOutputRestorePendingOverflow
? session.hiddenOutputRestoreReplayingSnapshot
: null
if (replayed) {
session.setRestoredSnapshotBaseline(ptyId, replayed, replayed.paintsContent === true)
session.noteHiddenOutputRestoreFloodBackpressure()
}
session.abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId, {
quiet: replayed !== null
})
}, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS)
}
@@ -1,5 +1,6 @@
import type * as ReactModule from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createEmptyRateLimitState } from '../../../shared/rate-limit-state-factory'
describe('useIpcEvents rate-limit hydration', () => {
beforeEach(() => {
@@ -9,17 +10,7 @@ describe('useIpcEvents rate-limit hydration', () => {
it('does not miss startup usage updates that land between get and subscription', async () => {
const setRateLimitsFromPush = vi.fn()
const staleState = {
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
inactiveCodexAccounts: []
}
const staleState = createEmptyRateLimitState()
const freshState = {
...staleState,
claude: {
+2 -17
View File
@@ -1,5 +1,6 @@
import type { StateCreator } from 'zustand'
import type { RateLimitRuntimeTarget, RateLimitState } from '../../../../shared/rate-limit-types'
import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory'
import type { AppState } from '../types'
export type RateLimitSlice = {
@@ -16,23 +17,7 @@ export type RateLimitSlice = {
}
export const createRateLimitSlice: StateCreator<AppState, [], [], RateLimitSlice> = (set, get) => ({
rateLimits: {
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null,
minimaxCookieConfigured: false,
minimaxApiKeyConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
inactiveCodexAccounts: []
},
rateLimits: createEmptyRateLimitState(),
fetchRateLimits: async () => {
try {
@@ -1,25 +1,9 @@
import type { PreloadApi } from '../../../../preload/api-types'
import type { RateLimitState } from '../../../../shared/rate-limit-types'
import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory'
import { noopUnsubscribe } from './web-storage'
export function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimits']> {
const empty: RateLimitState = {
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null,
minimaxCookieConfigured: false,
minimaxApiKeyConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
inactiveCodexAccounts: []
}
const empty = createEmptyRateLimitState()
return {
get: () => Promise.resolve(empty),
refresh: () => Promise.resolve(empty),
+10 -5
View File
@@ -47,12 +47,21 @@ export type AgentSessionAcquisitionDecision =
export type AgentSessionRestartAdjudication =
| { disposition: 'readopt' }
/** A journal settlement latch survives restart without changing its handoff stage. */
| { disposition: 'settlement-pending' }
/** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */
| { disposition: 'free'; reason: string }
| { disposition: 'evicted'; nextFence: number; evidence: AgentSessionDeathEvidence }
| { disposition: 'recovering'; stage: AgentSessionHandoffStage; reason: string }
| { disposition: 'conflicted'; reason: string }
export function agentSessionRestartEvictionSettlementId(
lease: Pick<AgentSessionLease, 'sessionId'>,
eviction: Extract<AgentSessionRestartAdjudication, { disposition: 'evicted' }>
): string {
return `restart-eviction:${lease.sessionId}:${eviction.nextFence}`
}
/** Stages that can legally admit a new owner at all; the rest have an owner or no evidence. */
const STAGES_ADMITTING_NEW_OWNER: ReadonlySet<AgentSessionHandoffStage> = new Set([
'old-owner-stopped',
@@ -201,11 +210,7 @@ export function adjudicateAgentSessionRestart(args: {
if (lease.settlementRetryRequired) {
// A watched provider death can leave terminal rows unsettled. This latch is not owner
// uncertainty and must survive restart until the journal settlement is durably accepted.
return {
disposition: 'recovering',
stage: 'recovering',
reason: 'provider-exit settlement requires retry'
}
return { disposition: 'settlement-pending' }
}
if (lease.reservedSpawnToken === null && lease.claimStatus !== 'reserved') {
// Why: the spawn token is minted before the child and is the only thing a child could be
+1
View File
@@ -48,6 +48,7 @@ export type GitHistoryItem = {
displayId?: string
author?: string
authorEmail?: string
/** Epoch milliseconds (git %at seconds × 1000). */
timestamp?: number
statistics?: GitHistoryItemStatistics
references?: GitHistoryItemRef[]

Some files were not shown because too many files have changed in this diff Show More