Skip to content

feat(hosted): new hosted installs run on the native engine; a migration moves the existing ones (TASK-265) - #2236

Merged
lilyshen0722 merged 8 commits into
mainfrom
lily/hosted-engine-migration
Oct 16, 2026
Merged

lilyshen0722 merged 8 commits into
mainfrom
lily/hosted-engine-migration

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What

TASK-265 finishes Stage 1 of the out-of-the-box plan. #2224 built the native engine for hosted agents, and this PR switches it on.

  1. New installs go native. values-dev sets backend.env.hostedEngine: "native", which becomes HOSTED_ENGINE. A new "Run it here" install runs in-process from creation. The chart value is empty by default, which keeps other deployments on the Durable Object.
  2. Existing installs move with a script: dist/scripts/migrate-hosted-to-native.js. It's a dry run by default; --apply changes rows. The rules live in services/hostedEngineMigration.ts.
    • It's forward-only and resumable, with no rollback.
    • Each row records config.hosted.migration { state: marked | stopped | done, doProvisioned }, and every run resumes each unfinished identity from there.
    • The steps, per agent identity:
      • refuse, changing nothing, when a Durable Object that still needs stopping is provisioned but the worker isn't configured;
      • mark every row native. From then the token issuer refuses on the stored engine and re-checks after saving, so a mint already in flight takes its own token back;
      • stop the Durable Object whenever the worker is configured (the stop is idempotent). The stop is recorded apart from the mark, so an interrupted stop is retried, never assumed;
      • revoke the identity's runtime tokens;
      • events: a recent pending event on a provisioned install goes to the in-process sweep, and any other is closed with a reason;
      • done.
    • The script never runs a turn.

Boundaries (from Rhea's reviews)

  • An unfinished migration is native for nothing. hostedEngineInEffect is native only once the identity is done. Execution (hostedRunsNative), grant eligibility, the installed-agent join check (runsInNarrowedProcess) and every join site use it. The issuer alone refuses on the stored engine. A source scan pins which files may read the stored engine.
  • A provision in flight can't undo a migration. The Durable Object branch writes its record only while the row is still on the Durable Object. If it loses that race, it stops the worker it started, revokes its token and answers 409. Deprovision is fenced the same way. The native branches write only their own fields.
  • Events are durable across the move. Enqueue decides routing before it inserts, so an event can land pending after the agent became in-process. In-process events are born delivered, so such an event is stranded by construction. The recurring in-process sweep runs it:
    • once it is a minute old, and not past half an hour;
    • picking candidates from identities with an in-process install only, using the issuer's filter;
    • deciding each one with the engine in effect;
    • claiming by compare-and-set, under the attempt cap.

Production today (read-only): 8 active hosted installs, all on the Durable Object with no engine recorded, and 0 pending events.

Rollout

  1. Merge and deploy. From then, new hosted installs are native.
  2. Run the dry run in the backend pod and post its report here.
  3. Run --apply, then the fresh-account "Run it here" walk: install, first contact, answer a mention, with an AgentRun recorded.

Rollback: set hostedEngine back to empty for new installs. A migrated install can return to the Durable Object by clearing config.hosted.engine and calling provision.

Tests

  • hostedEngineMigration (17 arms, in-memory Mongo with the real models):
    • a dry run changes nothing;
    • a clean run marks before it revokes;
    • each step's failure resumes on the next run: mark, stop, revoke and events;
    • persisted marked and stopped states resume;
    • unconfigured-worker refusal;
    • every identity stopped when the worker is configured;
    • after a failed stop or revoke, the stored rows are native for nothing, and once done, native for everything;
    • done plus a pending event: a rerun runs nothing, and the sweep runs it once;
    • born-native rows are untouched.
  • agentEventService.deliveryNonce:
    • the enqueue race through real enqueue and real rows: routed to the Durable Object, the migration finishes, the sweep runs the event once;
    • left alone: a Durable Object agent, an unfinished migration, an event under a minute or past half an hour old, and one at the attempt cap;
    • two racing sweeps run it once;
    • a backlog of 60 external pending events doesn't crowd it out.
  • Routes and policies:
    • routes/hosted: a migration landing mid-provision (409, record kept, worker stopped, token revoked twice, and the next provision mints nothing); a failed compensating stop; a deprovision overtaken by a migration; native branches never writing back a stale migration record;
    • eligibility and join policy: unfinished states refused, done and born-native allowed;
    • the issuer post-save re-check.
  • Mutations, each caught: 10 on the boundaries and 7 on the sweep, listed in the PR comments.
  • Wider run: 762/762 across the hosted, agentEvent, native runtime, outsider-grant, registry and agentsRuntime suites. tsc:check is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SECURITY-GATE: HOLD @ ad64c29 — Rhea

Four blocking migration findings:

  1. A token can be minted after revocation and survive the native transition. backend/services/hostedEngineMigration.ts:104 revokes before :118 saves the native marker. During that interval the existing issuer's runsInProcess check still sees only Durable Object installs. I interleaved the actual issueRuntimeTokenForAgent between those operations, with an installation argument as the installer route supplies. Migration returned success, both installations became native, and the new fake hash remained on the User. The actual agentRuntimeAuth then called next() for that fake bearer against stub stores. This is an executing check of the migration, revoker, issuer, and auth middleware at this head, not a live credential attack. Fence issuance/reprovisioning across the transition, including issuers already in flight; make the final native/no-token invariant hold under this interleaving. A final census alone does not close the race.

  2. Late failures leave an identity partly moved. At hostedEngineMigration.ts:108–118, each row is saved separately after stopping the DO and revoking tokens; :146–147 only records an error. Injecting failure on the second installation save left one native and one Durable Object installation, with the worker stopped and tokens revoked. Injecting a revocation failure left the worker stopped while both installations retained the old engine and token. The existing failure arm only throws on the first stop call, before these side effects. Provide an explicit recoverable, fail-closed transition with atomic database changes where appropriate and safe retry/reconciliation after external effects; do not claim errored identities are untouched. Cover failures after stopping, during revocation, on a later row, and during event settlement. A database transaction alone cannot undo a completed remote stop.

  3. Missing worker configuration silently skips shutdown. hostedEngineMigration.ts:90 requires both provisioned and isConfigured() to stop the DO, but otherwise proceeds to revoke and mark native. With a provisioned identity and configuration absent, my probe returned success with two native rows and zero stop calls. Token revocation does not delete the remote DO state or its alarm chain. Refuse that identity before mutations when its required shutdown cannot be attempted/confirmed. A missing local URL/admin token is not evidence the existing remote worker is stopped.

  4. The CLI exits while dispatched turns are still running. hostedEngineMigration.ts:144 calls the explicitly fire-and-forget dispatchInProcess (agentEventService.ts:1197–1263). backend/scripts/migrate-hosted-to-native.ts:22 disconnects Mongo, then :27 calls process.exit. Using the actual dispatcher method with a deferred fake runAgent, the actual CLI main() returned 0 and disconnected while the event was delivered and the turn unfinished; only releasing the deferred turn settled it. Await completion/settlement or hand off durably to the long-lived backend before exiting. TASK-264 can later recover interrupted delivered events, but it does not make this invocation complete its promised pending turns. The current test mocks the dispatcher and only asserts delivered, so it misses this boundary.

Validation: full six-file, +334-line merge-base diff read; diff check clean; base 4b21e89; final fetched/live head matches the SHA above. Rubric/checklist unchanged. My Node 22 exact-source VM probes used stub persistence/worker/model transport and fake crypto. Dry-run, successful migration and initial-stop-failure controls passed alongside the reproductions. No Jest/Mongo suite, live DO, real token, deployment, or fresh-account walk executed by me. The reported 12 suites/137 tests and three mutations are author evidence. Helm wiring was source-reviewed, not rendered by me.

Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Written by Sprint Review, a Commonly agent — gate requested in pod thread 77980.

Bound to ad64c294302aebbd9be80d1188a0cb5fcf8c4ef9. 6 files, +334/−0. 4 commits behind main with 0 conflict markers. CI: 13 pass, 3 skipping, Test & Coverage pending. BASE 5/5 on the migration suite; broader hosted sweep 25 suites, 387 tests, 0 failures. eslint 0 errors; tsc --noEmit 50, the baseline.

Most of this measures well. Four mutations, each red by name, totals held at 5:

mutation result
dry run no longer short-circuits 1 failed — a dry run … changes nothing
already-native installs no longer skipped (re-migration) 2 failed
stale events no longer closed 1 failed
provisioned state not preserved 1 failed

So the dry-run default, idempotency on re-run, the stale closure with its reason, and the preservation of provisioned-vs-stopped are all genuinely pinned. The closure reason does persist — AgentEvent declares error: String and failed is in its status enum, so this is not the stripped-field trap. The script is dry-run by default, --apply is explicit, and it exits 1 if any identity errored.

The hold: a recent pending event on an unprovisioned install is silently parked, and the dry run says otherwise

pendingRun is computed from isStale alone, before the apply pass adds a second requirement — a matching install with provisionedAt set:

const installation = rows.find((row) => String(row.podId) === String(event.podId));
if (!installation || !readHostedConfig(installation).provisionedAt) continue;

I derived that from reading and then demonstrated it, against the suite's own in-memory Mongo, using the fixture's existing unprovisioned install (idle-agent, provisionedAt: null) plus one pending event 60 s old:

DRYRUN  pendingRun=1  pendingClosed=0
APPLY   pendingRun=1  pendingClosed=0
DISPATCHES=0
EVENT_AFTER  status=pending  error=none

The report promises one event will run, in both passes. Zero run, and the event is left pending with no reason recorded.

Why this is the hold rather than a note:

  • The dry-run report is the operator's only decision input, and it is wrong for a reachable case — overstating what --apply will do. That is the one thing a dry run exists to prevent.
  • It contradicts this file's own stated contract: "a person's question is answered or visibly dropped, never silently parked." For this subset it is parked, silently.
  • It is terminal, not transient. After the migration the install is native-but-unprovisioned, so hostedRunsNative is false and nothing routes to it; the Durable Object is gone or never existed. #2226's restart sweep only reconsiders delivered events, not pending. So the event can never run, even if the owner provisions later.

Reachability is narrow — an active hosted install with provisionedAt falsy and a pending event newer than 10 minutes at migration time — but the fixture shows that install shape is real (a stopped install carries provisionedAt: null, deprovisionedAt: …), and the dry run will not warn the operator that those events need hand-holding.

Two ways to close it, and I would take the first:

  1. Treat a non-runnable pending event like a stale one — close it with a reason naming the cause, so it is visibly dropped. One continue becomes a close. This satisfies the docstring as written.
  2. Or exclude unprovisioned identities from pendingRun so the count is honest, and say in the report that those events are left as they are.

Either wants the arm this suite is missing: a recent pending event on an unprovisioned install. The existing event arm covers recent-vs-stale on a provisioned identity only, which is why this passed.

Smaller things, none blocking

  • buildAgentUsername(agentName, instanceId) is called with the raw name, where the rest of the codebase — including #2221's revoke, which I gated earlier today — passes resolveAgentType(agentName) first. I checked rather than filing it: LEGACY_AGENT_MAP holds exactly one entry (commonly-summarizer → commonly-bot), which is not a hosted runtime type, so the two are equivalent for the migrated population. It is a latent divergence, not a live bug — but if a legacy mapping is ever added for a hosted name, token revocation here silently finds no user and no-ops, and about silently stops being carried, because both hang off that same agentUser lookup.
  • provisionedAt: wasProvisioned ? now : null keeps the boolean but overwrites the original timestamp with the migration time. migratedAt records the migration separately, so nothing is lost that matters operationally, but an install's original provision date is gone.
  • Tokens are revoked before the rows are marked native, so a failure in between leaves an identity with no bearer and still on the Durable Object. That is recoverable — the skip is per-install, so a re-run picks up the rows that did not flip — and the per-identity try reports it. Worth knowing the window exists.

GATE: HOLD

samxu01 pushed a commit that referenced this pull request Oct 15, 2026
…nd runs no turns itself

Rhea's and sprint-review's HOLDs on #2236, per identity:
- Refuse (nothing changed) when the Durable Object is provisioned but the
  worker is not configured: it could not be stopped (Rhea 3).
- Mark every row native FIRST, so the token issuer refuses mints before the
  revoke; no window for a token to survive (Rhea 1). A failed row restores the
  rows already marked; a Durable Object that will not stop restores every row,
  so a failure leaves the identity as it was (Rhea 2).
- A failed revoke leaves the rows native (minting refused) and the Durable
  Object stopped, and says to run again; every run revokes for
  already-migrated identities, so the re-run finishes it.
- The script runs no turns (Rhea 4): recent pending events on a provisioned
  install are marked delivered for the in-process sweep (#2226) to run;
  recent ones on a stopped install, and stale ones, are closed with a reason
  (sprint-review). The dry run reports all three.
- The bot username goes through resolveAgentType, like the runtime's lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SECURITY-GATE: HOLD @ 846cea0 — Rhea

#2236 HOLD at 846cea0. The new ordering and normal handoff improve the first cut, but do not close the migration's concurrency and recovery requirements.

  1. In-flight issuance can still leave a live token after successful migration. The actual issuer checks runsInProcess once, then later saves the User token (registry/tokens.ts:242, :305). I started the actual issuer before marking, held its User save, completed the actual migration/revoker, then released the save. Both installs were native, the worker was stopped and the migration reported success, but a new fake hash remained; actual agentRuntimeAuth accepted it (next called once). Mark-before-revoke prevents new checks passing after the mark; it does not fence a request already past that check. Serialize/fence issuance and provisioning through their writes, including requests already in flight, and add that interleaving as an arm.

  2. A native marker is mistaken for completed shutdown. hostedEngineMigration.ts:163-168 records engine/migratedFrom before stopping the DO, but :104-121 treats all such rows as already migrated and retries only revocation. I suspended the first stop before it took effect after both marks, then invoked recovery against that persisted state without resuming the first invocation: recovery returned alreadyMigrated with no error, left the worker running, and never attempted another stop. This is also the persisted state of a process dying between marking and shutdown. Persist migration phases/completion independently of the routing marker; recovery must confirm shutdown before declaring the identity migrated. Preserve a fail-closed state while cleanup is incomplete.

  3. Failed compensation is silently called successful restoration. Both :174 and :187 swallow restore failures and report “restored, nothing changed.” Injecting failure on the second row's mark AND the first row's restore leaves [native, durable-object], the worker running and the old token present, with that false report. Track and report rollback failure and provide recoverable/fail-closed state; a failed database write cannot be repaired by assuming the next database write always works. Test both the primary and compensation failures.

  4. Pending work is stranded after an event-write failure or interruption. Writes at :207 and :217 escape the per-identity flow on rejection. After one failed event write, both rows are native; the next invocation takes :104-121 and never reprocesses pending events. My injected event-write failure threw from the migration, and a successful rerun left the event pending. Resume event reconciliation for incomplete/already-marked identities, make each phase idempotent, and keep per-identity failures from aborting the remaining identities.

Closed/positive controls: provisioned+unconfigured is now refused before mutation; dry run changes nothing; ordinary second-mark and initial-stop failures restore successfully; a revoke failure is reported and a rerun removes tokens without a second stop. The normal recent-event handoff DOES work with #2226: I composed the actual migration with extracted actual redispatchStuckInProcess and dispatchInProcess methods, actual hostedRunsNative classification, stub storage and run transport. The event is not picked before the age threshold, then is claimed once and reaches acked. The CLI no longer starts turns. This control does not cover the failed-write recovery in item 4.

Full correction and tests read: 2 files +231/-117 against the held parent; full PR 6 files +448, base 4b21e89. Prior unchanged chart/CLI review carries forward. Diff check clean, rubric/checklist unchanged and final fetched/live head matches. The PR body still describes the old order/direct execution and should be updated with the eventual correction.

Evidence is exact-source Node 22 VM execution with stub stores, fake hashes and worker/model transport; not Jest/Mongo, a real DO stop, real credentials, deployment, Helm render or fresh-account walk. Author-reported suite/mutation results are not my executions.

Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Written by Sprint Review, a Commonly agent — re-gate requested in pod thread 78010.

Bound to 846cea0cd5e036e83635d9a97f2ce03c03037340. My HOLD 5536734520 (at ad64c294) is superseded by this. 7 commits behind main, 0 conflict markers. BASE 8/8 (was 5); hosted sweep 25 suites, 390 tests, 0 failures. eslint 0 errors; tsc 50, the baseline. CI 14 pass, 3 skipping, Service Tests (Tier 1 — real DBs) pending.

My blocker is fixed on the first-run path, and fixed well

The rework does better than what I asked for. I suggested closing a non-runnable pending event with a reason; this splits the population into two arrays computed from the same predicates the apply pass iterates:

const toSweep = pending.filter((e) => recent(e) && podIsRunning(e.podId));
const toClose = pending.filter((e) => !toSweep.includes(e));

toClose is a true complement, so no event can fall between the buckets — that is a structural fix rather than a patched predicate, and it is why the report can no longer overstate. The reason text also distinguishes stopped from stale, which I had not asked for and which is the more useful message. pendingRun → pendingToSweep is a more honest field name too.

Three mutations, each red by name, totals held at 8:

mutation result
my original blocker restored — recent-on-stopped in neither bucket 1 failed
the close reason stops distinguishing stopped from stale 1 failed
the sweep handoff writes a nonce instead of null 1 failed

That third one matters more than it looks: #2226's claim filter is deliveryNonce: stale.deliveryNonce ?? null, so the null here is what lets the sweep claim these events at all. It is pinned.

My non-blocking note was taken too — the username now goes through resolveAgentType.

But the same defect I held on is still live, one branch over

@rhea's item 4 and my blocker are the same defect in two places, and I verified it by a different route than Rhea did — not by injecting a write failure, but by constructing the persisted state directly, because any interruption after marking produces it (a deploy, a pod restart, a kill). An identity whose rows are native and carry the migration marker, with one 60-second-old pending event:

DRY    alreadyMigrated=true  pendingToSweep=0  pendingClosed=0
APPLY  alreadyMigrated=true  pendingToSweep=0  pendingClosed=0
EVENT_AFTER  status=pending  error=none

The report says there is nothing to do, nothing is done, and the event stays pending with no reason — which is exactly the shape I held on at ad64c294. The !todo.length branch never queries AgentEvent and hardcodes pendingToSweep: 0, pendingClosed: 0 (:110), retrying only the revoke.

The hardcoded zeros are the tell: a report field that cannot be anything but zero on a path that can legitimately have work. The fix is to reconcile pending events in that branch the same way the first-run path does, which also makes the phase idempotent — the property Rhea asked for in item 4.

So the hold continues on my own ground, not as deference: the rework closed the first-run path and left the recovery path with the identical defect, and the recovery path is the one a half-finished migration lands on.

On Rhea's other three

Items 1–3 (in-flight issuance outrunning the mark, a native marker read as completed shutdown, and a failed compensation reported as success) are outstanding and I have not independently verified them — they are executed findings in Rhea's review, with a method I did not reproduce. I am not adopting them as mine, and I am not discounting them. Two of them are correctness rather than security, so they belong in this gate's scope as much as that one: a rollback that reports "restored, nothing changed" after a failed restore is the same class of defect as the report above, where the operator's only instrument says the wrong thing.

Worth noting for the press: this head now carries two HOLDs, and they do not overlap. Closing mine does not close Rhea's.

Also still true from my first review and unaddressed: the PR body describes the old direct-execution design, as Rhea noted.

GATE: HOLD

samxu01 pushed a commit that referenced this pull request Oct 15, 2026
…nd runs no turns itself

Rhea's and sprint-review's HOLDs on #2236, per identity:
- Refuse (nothing changed) when the Durable Object is provisioned but the
  worker is not configured: it could not be stopped (Rhea 3).
- Mark every row native FIRST, so the token issuer refuses mints before the
  revoke; no window for a token to survive (Rhea 1). A failed row restores the
  rows already marked; a Durable Object that will not stop restores every row,
  so a failure leaves the identity as it was (Rhea 2).
- A failed revoke leaves the rows native (minting refused) and the Durable
  Object stopped, and says to run again; every run revokes for
  already-migrated identities, so the re-run finishes it.
- The script runs no turns (Rhea 4): recent pending events on a provisioned
  install are marked delivered for the in-process sweep (#2226) to run;
  recent ones on a stopped install, and stale ones, are closed with a reason
  (sprint-review). The dry run reports all three.
- The bot username goes through resolveAgentType, like the runtime's lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
@samxu01
samxu01 force-pushed the lily/hosted-engine-migration branch from 846cea0 to 29e0879 Compare October 15, 2026 17:04
samxu01 pushed a commit that referenced this pull request Oct 15, 2026
…takes back an in-flight mint

Rhea's third review of #2236 (5536866789):
1. An in-flight mint survived: the issuer checks in-process once, then saves.
   Both issuers now re-check after the save and, if the identity went
   in-process meanwhile, revoke the token they just stored and refuse. A save
   that lands before the migration's revoke is caught by that revoke, so no
   interleaving leaves a live token.
2. A native marker was taken for a completed shutdown. Each row now records
   config.hosted.migration { state: marked | stopped | done, doProvisioned },
   so a run interrupted before the stop stops it on the next run.
3. A failed rollback was reported as a clean restore. There is no rollback
   now: the migration is forward-only, each step idempotent, and a failure
   stops that identity at the step reported.
4. Events were stranded after a write failure. Every unfinished identity is
   resumed from its recorded state, events included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
samxu01 pushed a commit that referenced this pull request Oct 15, 2026
… handles pending events

Sprint-review on #2236 (5536956805): an interruption after marking (deploy,
pod restart, kill) leaves rows in a persisted state with no failure to
inject. The arm builds that state directly, in both 'marked' and 'stopped':
the dry run counts the pending event from the same query, and apply resumes,
hands the event to the sweep and ends done. Reintroducing the old 'resume
skips events' shape fails both arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SECURITY-GATE: HOLD @ a600bf3 — Rhea

#2236 HOLD at a600bf3. The issuer rechecks and durable migration phases fix the earlier specific resume interleavings. Two required boundaries remain open.

  1. Unfinished migration is not fail-closed for execution or connector eligibility. hostedEngineMigration.ts:155-163 writes engine:native with a non-null provisionedAt before shutdown or revocation. On stop failure it leaves state marked; on revoke failure it leaves state stopped. Neither hostedRunsNative (hostedEngine.ts:70-74) nor outsiderGrantPodRefusal (outsiderGrantPodEligibility.ts:74-76) checks this state; runtime authentication also continues accepting the old token. In exact-source composition, for BOTH failed-stop and failed-revoke states, the actual eligibility function returned null in an otherwise eligible pod, actual hostedRunsNative returned true, and actual agentRuntimeAuth accepted the old fake token (next=1). On failed stop the worker was still running. Thus an identity becomes eligible for outsider-writable grants while its external identity channel is still live, precisely the premise #2224/§12 requires absent. Keep migration intent separate from an executable/eligible native state, or gate execution and grant mint/list/call on completion while retaining issuance refusal during migration. Add explicit unfinished-state denial arms with completed/born-native positives. Successful later resumption does not protect the interval before it.

  2. Provisioning is still unfenced and can undo a successful migration. The existing hosted provision handler selects the old engine and mints, awaits the worker call, then unconditionally replaces config.hosted (routes/hosted.ts:249-261). I ran that actual handler and actual issuers/revoker: paused its first worker provision before effect, completed the migration to done, then released provision. The handler returned 200, restarted the worker and replaced the native engine/migration record with the DO-shaped config. A second ordinary provision returned 200 and minted a fresh active ledger/User token. This fixture has one active install for the identity; the hydrated-store stub only saves config after the route explicitly modifies config, so the result does not depend on re-saving an unmodified token array. The post-save issuer check passed before migration began and cannot fence the later worker/config writes. Serialize/fence the full provision/deprovision lifecycle against migration, including requests already in flight, and test this interleaving. The first resumed worker receives a revoked token; the second provision is what restores a live token.

Passing controls at this head: clean completion and repeat no-op; dry run; unconfigured-worker refusal; interrupted mark, failed stop, failed revoke and failed event write all resume to done; persisted marked and stopped states resume without injected failure (one and zero stop calls respectively). Both the shared and legacy issuer, held at save until migration completes, now refuse with in_process_agent_no_token and remove the just-saved hash from their stores/ledger. The previous normal sweep-handoff proof carries: agentEventService and hostedEngine source are byte-identical to the previously probed versions. No rollback remains to misreport.

Read the full new migration, its 11 arms, both issuer additions and the added route regression; full PR is 8 files +567 at base 0aac93e. Compared the four carried CLI/chart patch payloads across rebase: identical. Diff check clean; rubric/checklist unchanged; final fetched/live head matches. The PR's Tests section still describes some original direct-run behavior/counts and should be refreshed with the correction.

My evidence is actual-source Node 22 VM composition with stub persistence, fake hashes/ledger and worker/model transport, not Jest/Mongo, real credentials, a live DO, deployment or a fresh-account walk. The reported 32 suites/462 tests and mutations are the author's executions.

Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Written by Sprint Review, a Commonly agent — re-gate requested in pod thread 78037.

Bound to a600bf3dd9f550ec7180e253d57f96d810583de0. My HOLD 5536956805 (at 846cea0c) is superseded. PR's own content: 8 files, +567/−0, 4 commits, level with main, 0 conflict markers. BASE 11/11 (was 8). eslint 0 errors; tsc 50, the baseline. CI 12 pass, 3 skipping, Test & Coverage and kind cluster smoke test pending. No other review at this head yet.

My finding is fixed, verified with the identical probe that found it

The !todo.length early return is gone. The skip is now !unmarked.length && !unfinished.length, and AgentEvent.find runs for every identity that gets past it — before the report entry is built, so the counts and the work come from one query on the resume path exactly as on the first-run path.

Same instrument, same fixture shape, before and after:

at 846cea0c   DRY/APPLY pendingToSweep=0  pendingClosed=0   EVENT status=pending  error=none
at a600bf3d   DRY from=marked toSweep=1   APPLY reached=done  EVENT status=delivered nonce=null

Two details that make it more than a patch. The row shape I probed with carries migratedFrom but no state, and it resolves as from=marked — so a row left by any earlier version of this script resumes rather than being skipped, which is the right forward default. And the handed-off event ends with deliveryNonce: null, which is what #2226's deliveryNonce: stale.deliveryNonce ?? null CAS needs in order to claim it at all.

mutation result
restore "resume skips events" 5 failed
report counts hardcoded to 0 (my exact defect) 3 failed
issuer post-save re-check removed 2 failed
re-check refuses but does not take the token back 1 failed

Row one: the ask said 3 arms; I measured 5 on my mutation of the skip condition. Reporting my own number — a different mutation of the same property may well red exactly 3, and either way it is well pinned. Row two is the direct regression guard for the defect I held on, which is the one I most wanted to see exist.

On the two of Rhea's findings this changes structurally

Not my verdict to give — @Rhea should confirm from their side — but worth recording what I observe, since it changes what there is to check:

  • Item 2 (a marker read as completed shutdown) is answered by recording stopped separately from the mark, with an arm named for it: a stop that failed is retried on the next run, not assumed.

  • Item 3 (failed compensation reported as successful restoration) is dissolved rather than fixed: the design is now "forward-only and resumable, with no rollback", and grep -niE "restore|rollback|revert|compensat" finds nothing but that header line. There is no compensation left to fail, so the defect class is gone. That is the stronger answer — a rollback that can itself fail is a second thing to get right, and this removes it instead.

  • Item 1 is the post-save re-check in tokens.ts. I walked the interleavings rather than taking the comment: mint-checks → migration marks → migration revokes → mint saves → re-check catches it; and mint saves before the migration's revoke → that revoke catches it. Because the re-check sits after the save, no save can outrun it, so every ordering ends with no live token.

    One residual, not worth a fence: between the save and the re-check the token is genuinely live in the database and would authenticate. The window is a single await and the token is taken back at the end of it.

One thing I have to report about my own run

A single test failed in the first of five runs of the same broad sweep (hosted|runtime-tokens|agentEvent) over this tree — 1 failed / 481 passed / 482. I did not capture its name: I had not redirected that run's output, and it did not reproduce. Four subsequent runs of the identical command were clean at 482/482, exit 0.

So I cannot tell you what failed, which is a slip in my method rather than a finding about this PR — the rule is failures by name, and I lost the name by not capturing the first run. Recording it anyway because a roughly one-in-five flake in this neighbourhood will eventually redden CI and be read as a real regression by whoever meets it.

GATE: PASS

samxu01 pushed a commit that referenced this pull request Oct 15, 2026
…nd runs no turns itself

Rhea's and sprint-review's HOLDs on #2236, per identity:
- Refuse (nothing changed) when the Durable Object is provisioned but the
  worker is not configured: it could not be stopped (Rhea 3).
- Mark every row native FIRST, so the token issuer refuses mints before the
  revoke; no window for a token to survive (Rhea 1). A failed row restores the
  rows already marked; a Durable Object that will not stop restores every row,
  so a failure leaves the identity as it was (Rhea 2).
- A failed revoke leaves the rows native (minting refused) and the Durable
  Object stopped, and says to run again; every run revokes for
  already-migrated identities, so the re-run finishes it.
- The script runs no turns (Rhea 4): recent pending events on a provisioned
  install are marked delivered for the in-process sweep (#2226) to run;
  recent ones on a stopped install, and stale ones, are closed with a reason
  (sprint-review). The dry run reports all three.
- The bot username goes through resolveAgentType, like the runtime's lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
samxu01 pushed a commit that referenced this pull request Oct 15, 2026
…takes back an in-flight mint

Rhea's third review of #2236 (5536866789):
1. An in-flight mint survived: the issuer checks in-process once, then saves.
   Both issuers now re-check after the save and, if the identity went
   in-process meanwhile, revoke the token they just stored and refuse. A save
   that lands before the migration's revoke is caught by that revoke, so no
   interleaving leaves a live token.
2. A native marker was taken for a completed shutdown. Each row now records
   config.hosted.migration { state: marked | stopped | done, doProvisioned },
   so a run interrupted before the stop stops it on the next run.
3. A failed rollback was reported as a clean restore. There is no rollback
   now: the migration is forward-only, each step idempotent, and a failure
   stops that identity at the step reported.
4. Events were stranded after a write failure. Every unfinished identity is
   resumed from its recorded state, events included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
@samxu01
samxu01 force-pushed the lily/hosted-engine-migration branch from a600bf3 to f747efc Compare October 15, 2026 17:42
samxu01 pushed a commit that referenced this pull request Oct 15, 2026
… handles pending events

Sprint-review on #2236 (5536956805): an interruption after marking (deploy,
pod restart, kill) leaves rows in a persisted state with no failure to
inject. The arm builds that state directly, in both 'marked' and 'stopped':
the dry run counts the pending event from the same query, and apply resumes,
hands the event to the sweep and ends done. Reintroducing the old 'resume
skips events' shape fails both arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
samxu01 pushed a commit that referenced this pull request Oct 15, 2026
…vision in flight cannot undo one

Rhea's HOLD on #2236 at a600bf3 (two boundaries):

1. A row the migration marked native counted as native for execution, grant
   eligibility and joins while its Durable Object could still be running (a
   failed stop) or its old token still live (a failed revoke). The engine in
   effect (hostedEngineInEffect) is now native only once the migration records
   the identity done; hostedRunsNative, runsInNarrowedProcess and every join
   site use it. The token issuer still refuses on the stored engine, so an
   unfinished row can neither mint nor count as in-process. A source scan pins
   which files may read the stored engine.

2. A Durable Object provision in flight across the migration saved the record
   it read at the start over the migration's, and the next provision minted a
   fresh token. The DO branch's record write is now conditional on the row
   still being on the Durable Object; when the migration got there first, the
   route stops the worker it started, takes back its token and answers 409.
   Deprovision is fenced the same way, and the native branches write only
   their own fields, so neither can revert a migration's recorded state.
   The migration now stops every identity whenever the worker is configured
   (the worker's stop is idempotent), since a provision can land between the
   migration's read and its mark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
@lilyshen0722

lilyshen0722 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Rhea's HOLD at a600bf3 is addressed at f747efc, plus b42558a (see the correction at the end). That head is also rebased onto main (#2239, #2234).

1. An unfinished migration is native for nothing

The new hostedEngineInEffect(installation) is native only once a migration has recorded the identity done. A row the migration marked, with or without a state, reads as the Durable Object.

These now use it:

  • hostedRunsNative, which covers execution: routing, the native turn, and status labels;
  • runsInNarrowedProcess, which covers grant eligibility (mint, list, call) and the installed-agent join check;
  • all five join sites that pass an engine string: agentAutoJoinService, two in agentsRuntime, agent-config, reprovision and provision.

The token issuer keeps refusing on the stored engine, so an unfinished row can neither mint nor count as in-process. That is the separation you asked for.

A source scan pins that only hostedEngine.ts, the migration and routes/hosted.ts read the stored engine.

New arms:

  • Eligibility: non_native_agent for a migration that is marked, stopped, or recorded with no state; eligible once done.
  • Join policy: refused while unfinished and allowed once done, on both the installed-agent path and the engine-string path.
  • Migration, on the real stored rows: after a failed stop and after a failed revoke, hostedEngineInEffect is durable-object, and hostedRunsNative and runsInNarrowedProcess are false. After the resume they are all native, and a born-native row is native throughout.

2. A provision in flight cannot undo a migration

  • DO provision: the record write is now an updateOne conditional on config.hosted.engine not being native, using the issuer's spelling. If the migration got there first, the route stops the worker it started, takes back the token it minted before the mark, and answers 409 hosted_engine_migrated. A failed stop still takes the token back.
  • DO deprovision: fenced the same way. The worker is stopped either way, and a migrated row keeps the migration's record.
  • Native branches: they write only config.hosted.provisionedAt (and deprovisionedAt), so a request that read marked cannot write it back over done.
  • The migration's stop: it now stops every identity whenever the worker is configured, not only identities it read as provisioned. A provision can land between the read and the mark, and the worker's /deprovision is idempotent (deleteAll and deleteAlarm).

Your interleaving is now a route arm. The worker call is held while the row is migrated to done. The arm asserts:

  • the result is 409;
  • the migrated record is unchanged;
  • the worker is stopped;
  • the token is revoked twice, once before the mint and once after;
  • a second provision takes the native branch: no mint and no worker call.

Also covered:

  • the failed-stop variant;
  • a deprovision overtaken by a migration;
  • for both native branches, a read snapshot whose save persists, with the migration finishing between the read and the write.

Proof

  • 775/775 across the hosted, native runtime, outsider-grant, auto-join, agentEvent, agentState, registry and agentsRuntime suites. tsc:check is clean.
  • Nine mutations, each caught (78-test core set):
mutation failed
unfinished never counts 6
runsInNarrowedProcess on the stored engine 6
hostedRunsNative on the stored engine 2
conditional write removed 3
compensating stop removed 1
compensating revoke removed 2
native branch writes the whole record 1
stop decided by the read 1
an auto-join site back on the stored engine 1

Correction (edited, b42558a): an earlier version of this comment called one gap a residual that "the next run handles". That was false: the script skips identities already done, and nothing else runs or closes a pending event for one; it would have been garbage-collected unanswered.

The gap: an event enqueued between the events step and done goes to the Durable Object path, because the rows are not native until done. b42558a now hands off whatever is pending right after done, the same way as step 4, and reports the count as lateEvents. A new arm creates an event mid-run (during the revoke) and asserts it ends delivered with no nonce. Removing the hand-off fails that arm.

The head for gates is b42558a.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SECURITY-GATE: HOLD @ b42558a — Rhea

#2236 HOLD at b42558a: the late-event handoff still strands events. The two security findings in review 5537036299 are closed at this head; the remaining defect is event delivery/recovery.

HOLD — a final scan is not a durable handoff. hostedEngineMigration.ts:127 skips done identities, :261 records done, and :273–279 performs a single late pending scan. I reproduced two arms against this exact source:

  1. No failure needed: run the actual AgentEventService.enqueue and resolveInProcessInstallation methods with a deferred AgentEvent.create. Routing resolves the DO installation first (agentEventService.ts:1297), then creation waits. Run the actual migration to completion: worker stopped, tokens revoked, both rows done, lateEvents: 0, no error. Release creation. The actual enqueue persists a pending event and dispatches zero in-process turns because its pre-insert routing decision is still false (:1304–1340). A healthy migration rerun reports zero identities and leaves that event pending. This is an in-flight database insert crossing the scan, not a forged event or a model of enqueue logic.
  2. Failed late write: create a recent event during revoke, then fail only that event's write in the late handoff. The migration records done and reports the manual-check error at :278; restore the store and rerun, and it skips the identity with the event still pending. An interruption after done and before the late scan has the same persisted recovery gap.

The #2226 recovery selector at agentEventService.ts:902–906 only selects delivered events, so it cannot rescue these pending rows. The ordinary late-arrival positive control (event created during revoke, successful late write) does become delivered. That control confirms the new handoff works for the arrival window its test covers, but not for an insert still in flight or an interrupted/failed handoff.

Please make pending-event recovery durable for migrated identities, including those already done, and close the enqueue/read-before-insert race. A bounded recurring recovery path or equivalent durable coordination can do this; another one-shot scan or moving done alone does not close both arms. Add the deferred-create and persisted-done-plus-pending recovery cases. Preserve the current no-turns-in-CLI behavior and the stopped/stale event policy.

Closed findings and controls: actual migration + engine + outsider eligibility/join policy refuse marked/stopped identities for execution/grants/joins while the actual issuer still refuses minting. Actual old-token auth still passes before revoke, but those identities no longer qualify as narrowed native. Completed and born-native positives pass. Actual hosted provision paused after mint across the entire migration now returns 409, keeps the done native record, stops the restarted worker, and clears the fake token stores/ledger; the next provision takes native with no worker or token. The analogous deferred deprovision preserves the migration record. Both issuer-save races, dry run, clean run, unconfigured refusal, ordinary mark/stop/revoke/event failure recovery, and independently persisted marked/stopped resume controls pass.

Evidence scope: my Node 22 VM probes use the exact migration, issuers/revoker, auth, hosted route, engine, eligibility and join modules; the enqueue/routing methods are extracted verbatim from the same head. Database, worker and transport dependencies are stubs, with fake hashes. No Jest/Mongo run, live DO, real credentials, deploy, Helm render or authenticated walk. The author's 775 tests and 10 mutations are their evidence, not mine. Prior delivered-event-to-sweep composition proof carries forward; its source is unchanged apart from the engine predicate under review.

Reviewed the full 18-file +1011/-38 scope. Range-diff confirms the four prior commits replay identically, followed by the two new fixes; read the new source/tests and wiring. Rubric/checklist unchanged. Diff check clean; final fetched ref matches the live PR head; base/main 1d119ef. PR body still promises every failed step resumes and its Tests section retains older direct-run/stopped-worker descriptions; update it with the eventual fix.

Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Written by Sprint Review, a Commonly agent — re-gate requested in pod thread 78064.

Bound to b42558aa51178d189e9f8680a17192d8afe9d1ad. My PASS 5537116056 (at a600bf3d) is superseded. 18 files, +1011/−38, level with main, 0 conflict markers. BASE 16/16 on the migration suite, 152/152 across the focused set; broader sweep over hosted/outsider-grant/runtime-tokens/agent-event/native-runtime: 43 suites, 582 tests, exit 0. eslint 0 errors; tsc 50, the baseline. CI 12 pass, 2 skipping, Test & Coverage and kind cluster smoke test pending. First review at this head.

The engine split is the right shape, and both directions are conservative

The hostedEngineOf / hostedEngineInEffect split is the structural answer to "a marker is not a shutdown", and what makes it correct is that the two readers are deliberately conservative in opposite directions:

  • The issuer refuses early. It keys on the stored engine (its own config.hosted.engine regex), so the moment the migration marks a row native, no new token can be minted.
  • Execution and eligibility wait for late. They key on hostedEngineInEffect, which is native only at migration.state === 'done' — so a half-migrated row is never treated as having no channel outside this process.

An unfinished row therefore cannot mint a token and does not count as narrowed. Both doors shut, by two rules that would each be wrong if applied to the other site. I enumerated every caller rather than sampling: hostedEngineOf appears in exactly three files (the engine module, the migration, the hosted route — all of which operate on the lifecycle), and hostedEngineInEffect at the six join/eligibility sites plus the two boundary helpers.

mutation result
engine-in-effect ignores unfinished migrations 5+ failed across eligibility, joins and migration
a migrated row with no recorded state counts as finished 4 failed
the Durable Object write CAS removed (blind save) 3 failed — incl. the 409 and the token take-back
the late-event hand-off removed 1 failed — an event that arrives during the run is handed off after done, not stranded

That last row closes the final window of my original finding: an event arriving during the run, after the first query. Step 6 re-queries and hands off, and removing it reds the arm.

The field-scoped $sets replacing whole-object config.set('hosted', …) saves are the better fix for the lost-update class — more surgical than a CAS, because the route now writes only the fields it owns and cannot clobber a migration record it never read.

The source-scan guard: what it covers, measured

The allowlist test is a good idea — it defends the split I verified by hand, so a future site that reads the stored engine fails rather than silently diverging. I measured its exact reach with three mutations:

mutation guard
a new file under services/ reads hostedEngineOf caught
an extra read inside routes/hosted.ts (an allowed file) green
a read added under backend/scripts/ green

So it is file-granular, not call-granular, and it walks only services, routes, middleware, controllers. Neither limit is live today — my enumeration found no stored-engine reader outside those three files — but hosted.ts already holds four reads, so a fifth that should have been in-effect is exactly the change this guard would wave through. Counting occurrences per allowed file, and adding scripts/integrations to the walk, would close both; toEqual over a sorted list already gives it the stricter both-ways property, which is worth keeping.

One observation, deliberately not a finding

Deprovision on a row whose stored engine is already native takes the record-only path and does not stop the Durable Object, so for a row marked-but-unfinished it reports {deprovisioned: true} while the worker may still be running, relying on the migration's stop step to run on a later pass.

I had this as a candidate finding and then found the arm that settles it: "%s on a migrating row never writes back the migration record it read" covers both routes with state: 'marked', so this path is deliberate and tested for the property that matters. Under the state machine the stop does happen — on resume — and a migration that is never re-run is already the operator's responsibility by design. Recording where the responsibility sits rather than calling it a defect.

GATE: PASS

samxu01 pushed a commit that referenced this pull request Oct 15, 2026
…ove in-process

Rhea's HOLD on #2236 at b42558a: a one-shot scan is not a durable handoff.
Enqueue decides routing before it inserts, so an event enqueued while the
hosted migration runs can persist 'pending' after the agent became
in-process, and a rerun skips a done identity. A failed or interrupted late
scan stranded events the same way.

In-process events are born 'delivered', so a 'pending' event whose agent now
resolves in-process is stranded by construction. The recurring in-process
sweep (garbageCollect, beside #2226's restart re-run) now runs those:
- once they are a minute old (never racing an enqueue), and not past half an
  hour (left to the retention delete);
- candidates limited to identities with an in-process install (the filter
  the token issuer uses, now shared from hostedEngine), so a backlog of
  external agents' pending events cannot crowd them out;
- each decided by enqueue's own routing with the engine in effect, so an
  unfinished migration is left alone;
- claimed by compare-and-set from 'pending', counted as a delivery, under the
  attempt cap.

The migration's one-shot late scan is removed; the script still runs no turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Rhea's event-delivery HOLD at b42558a is addressed at 2b69bd5, and the PR body is refreshed.

I agree a scan can't be made durable, so the one-shot late scan is gone. In-process events are born delivered (enqueue). A pending event whose agent now resolves in-process was therefore routed before the move and is stranded by construction, which gives the recurring sweep a clean selector. garbageCollect now runs rescueStrandedPendingInProcess next to #2226's re-run:

  • Window: pending events at least a minute old, so the sweep never races an enqueue, and at most half an hour old. Older ones go to the retention delete.
  • Candidates: only identities with an in-process install, selected with the issuer's own filter (now shared from hostedEngine). A backlog of external agents' pending events can't crowd them out.
  • Decision: each candidate goes through enqueue's routing with the engine in effect, so an unfinished migration is left alone until it's done.
  • Claim: compare-and-set from pending, counted as a delivery and under the attempt cap.

This covers both of your cases, plus an interruption between done and the old scan, because it no longer depends on the script.

Arms

  • The enqueue race, through real enqueue and real rows: routed to the Durable Object while the row is hosted, the migration finishes, and the sweep runs the event once. A second sweep doesn't run it again.
  • Done plus pending, with the real migration: a rerun of the script skips the identity and runs nothing; the sweep runs the event once.
  • Unfinished, then done: the sweep leaves the event alone while the stop has failed. The resumed run's step 4 hands it on, and the script still runs no turn.
  • Left alone: a Durable Object agent, an unfinished migration, an event under a minute old, one past half an hour, and one at the attempt cap.
  • Two racing sweeps run it once.
  • A backlog of 60 external events doesn't crowd it out.

Mutations (seven, each caught): the sweep not called, no compare-and-set, no minimum age, no maximum age, no candidate filter, no attempt cap, and routing replaced by any installation. That last one fails the unfinished-migration arms; the Durable Object arm holds through the candidate filter.

762/762 across the hosted, agentEvent, native runtime, outsider-grant, registry and agentsRuntime suites. tsc:check is clean.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SECURITY-GATE: HOLD @ 2b69bd5 — Rhea

#2236 HOLD: the pending recovery batch can repeatedly select only unfinished migrations and never reach a completed agent's event before the recovery window closes.

The two isolated stranded-event cases from review 5537565612 now recover. I ran the actual migration, held the actual extracted enqueue's AgentEvent.create across completion, released the pending insert, and then ran the actual extracted resolver, rescue method and dispatch method. The completed migration's rerun still skips it, but the sweep claims and dispatches it once. The done-plus-pending state also recovers after an injected failed sweep claim. Two concurrent sweeps total one claim, one stubbed run and one nonce-matched stubbed ack. The one-shot late scan is gone.

One remaining blocker is in backend/services/agentEventService.ts:979–1005. IN_PROCESS_RUNTIME_FILTER deliberately admits stored-native unfinished migrations. The query sorts all their pending events and limits to 50 at line 996 before resolving the engine in effect. A non-runnable event is then skipped without advancing a cursor or otherwise changing which batch the next pass selects.

Independent exact-head reproduction, with query-aware in-memory stores:

  • One active hosted identity has stored engine native, migration stopped, and 50 pending events. The real resolver correctly refuses to run it.
  • A second active hosted identity has migration done and a provisioned install. Its pending event was created 1 ms after those 50 events. All are about two minutes old, below the attempt cap.
  • Invoke the actual rescue method at successive simulated minute boundaries for 31 passes. Every eligible pass selects the same 50 unfinished events; zero claims and zero runs occur. The completed agent's event remains pending with attempts 0 after passing the 30-minute upper bound, so future passes cannot recover it either. The deployed scheduler's ten-minute cadence also has this failure; the faster probe does not rescue it.
  • Positive controls: with only 49 unfinished candidates, the same completed agent's event runs and acks once; 60 genuinely external-identity events do not crowd it out. This is the gap between the external-backlog test and the broader stored-engine candidate set.

Please make recovery advance past non-runnable candidates while preserving bounded work and the engine-in-effect check. Filtering eligible candidates before the batch cap, or a fair paginated/resumable scan, can address it; simply raising 50 moves the threshold. Add the unfinished-backlog/completed-event arm and demonstrate recovery before that event ages out. Do not run the unfinished identity to clear the queue.

Other checks passed again: dry run; clean migration and no-op rerun; persisted marked/stopped resumes; unconfigured refusal; mark/stop/revoke/event-write recovery; unfinished execution/eligibility/join refusal and completed/born-native controls; issuer post-save races; provision/deprovision races retaining done and compensating the worker/token. Sweep controls pass at just over one minute and exactly thirty minutes; exactly one minute, over thirty minutes, exhausted attempts and unfinished migration do not run. The issuer's moved filter has the same predicates as before.

Evidence scope: Node 22 VM, exact git sources, query-aware stub stores, fake worker/tokens, extracted production enqueue/resolver/rescue/dispatch methods, stubbed runAgent and acknowledge. Not a Jest/Mongo run, live worker probe, deployment or Helm render. CTO's 762/762, tsc and mutation counts remain author evidence. The initial probe reused a fixture before its asynchronous ack settled; I corrected the fixture sequencing and reran cleanly before recording the result.

Reviewed all six files of the direct-parent correction (+253/-51), carrying the previously reviewed unchanged payload. Full merge-base scope: 20 files, +1226/-51; base 1d119ef. Final fetched/live PR head matches the stamp; final main is 37f212e. Rubric/checklist unchanged. Nonblocking diff-check nit: new blank line at EOF in hostedEngineMigration.test.js:343.

Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Written by Sprint Review, a Commonly agent — re-gate requested in pod thread 78086.

Bound to 2b69bd573031817bf197729e27cd619a819f1a4f. My PASS 5537596921 (at b42558aa) is superseded. Delta since it: 6 files, +253/−51. 4 behind main, 0 conflict markers. BASE 61/61 across the three touched suites; sweep over agent-event/hosted/runtime-tokens: 31 suites, 503 tests, exit 0. CI 15 pass, 3 skipping — nothing pending. eslint 0 errors; tsc 50, the baseline.

The rescue's own guards hold: removing the in-process identity restriction reds a backlog of external agents' pending events does not crowd it out, and weakening the compare-and-set reds two sweeps racing (two replicas) run it once. (A third mutation — dropping the engine-in-effect resolution — produced a compile/collection error rather than a clean read, so I am not reporting a result from it.)

I confirm @Rhea's blocker from the code, and it is in the ordering

rescueStrandedPendingInProcess selects by $or: pairs from IN_PROCESS_RUNTIME_FILTER, sorts createdAt: 1, applies .limit(STUCK_REDISPATCH_BATCH) — and only then resolves resolveInProcessInstallation per event, continue-ing when the migration is not done. So the cap is applied to a candidate set that deliberately includes stored-native unfinished rows, oldest-first, and a non-runnable candidate consumes a slot without advancing anything. Rhea's 50-unfinished-events reproduction follows directly from that ordering.

My own blocker: the 30-minute ceiling is terminal and silent

This is distinct from the starvation, and it needs no starvation, no injected failure and no unfinished migration. The query bounds candidates with $gte: now - STRANDED_PENDING_MAX_AGE_MS (30 min), and nothing closes a pending in-process event that passes it.

Measured on the suite's own harness — a fully migrated identity (migration.state: 'done', provisioned), one pending event aged 31 minutes:

runAgent calls: 0
after sweep pass 1: status=pending  attempts=0  error=none
after sweep pass 2: status=pending  attempts=0  error=none

Neither run nor closed, repeatably. I checked what else might catch it: the migration's toClose still closes stale pending events, but only for events that exist at migration time; the requeue and expiry passes act on delivered, not pending; and no other writer moves a pending event to a terminal state on age.

So any cause that delays a pending in-process event past thirty minutes makes it permanent and invisible: a deploy window, a stopped scheduler, an incident — or Rhea's starvation, which is one route to the boundary rather than the boundary itself. Fixing the batch ordering removes the most likely route and leaves the cliff in place.

This is the same defect I first held on, relocated. My original HOLD was that a non-runnable pending event was silently parked with no reason recorded; the rework answered it by closing such events with a reason, which was the right idiom. The rescue simply does not apply that idiom at its upper bound. The remedy is the one already in this codebase: when a candidate ages past the ceiling, close it failed with a reason naming the cause, so the person's question is visibly dropped rather than parked. That also makes the condition observable instead of inferable from an absence.

I would rather see the ceiling closed than widened — raising 30 minutes moves the cliff exactly as raising 50 moves the starvation threshold.

Scope note

Rhea holds on the batch ordering and asked for an unfinished-backlog arm. I hold on the ceiling. They are different defects with different triggers and need separate arms — an arm proving recovery before age-out does not prove anything about an event that ages out anyway. Closing either does not close the other.

GATE: HOLD

lilyshen0722 and others added 5 commits October 15, 2026 14:55
…on moves the existing ones (TASK-265)

values-dev sets backend.env.hostedEngine "native" (HOSTED_ENGINE), so a new
"Run it here" install runs in-process from creation (#2224). The chart value
is empty by default, which keeps the Durable Object.

scripts/migrate-hosted-to-native (dry run by default, --apply) moves the
existing installs, per agent identity: stop its Durable Object, revoke its
runtime tokens, mark each of its active hosted installs native (keeping
whether it was provisioned, carrying the seat's persona sentence), run recent
pending events in-process and close stale ones with a reason. One identity
failing is reported and does not stop the others. Production today: 8 active
hosted installs, all on the Durable Object, 0 pending events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
…nd runs no turns itself

Rhea's and sprint-review's HOLDs on #2236, per identity:
- Refuse (nothing changed) when the Durable Object is provisioned but the
  worker is not configured: it could not be stopped (Rhea 3).
- Mark every row native FIRST, so the token issuer refuses mints before the
  revoke; no window for a token to survive (Rhea 1). A failed row restores the
  rows already marked; a Durable Object that will not stop restores every row,
  so a failure leaves the identity as it was (Rhea 2).
- A failed revoke leaves the rows native (minting refused) and the Durable
  Object stopped, and says to run again; every run revokes for
  already-migrated identities, so the re-run finishes it.
- The script runs no turns (Rhea 4): recent pending events on a provisioned
  install are marked delivered for the in-process sweep (#2226) to run;
  recent ones on a stopped install, and stale ones, are closed with a reason
  (sprint-review). The dry run reports all three.
- The bot username goes through resolveAgentType, like the runtime's lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
…takes back an in-flight mint

Rhea's third review of #2236 (5536866789):
1. An in-flight mint survived: the issuer checks in-process once, then saves.
   Both issuers now re-check after the save and, if the identity went
   in-process meanwhile, revoke the token they just stored and refuse. A save
   that lands before the migration's revoke is caught by that revoke, so no
   interleaving leaves a live token.
2. A native marker was taken for a completed shutdown. Each row now records
   config.hosted.migration { state: marked | stopped | done, doProvisioned },
   so a run interrupted before the stop stops it on the next run.
3. A failed rollback was reported as a clean restore. There is no rollback
   now: the migration is forward-only, each step idempotent, and a failure
   stops that identity at the step reported.
4. Events were stranded after a write failure. Every unfinished identity is
   resumed from its recorded state, events included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
… handles pending events

Sprint-review on #2236 (5536956805): an interruption after marking (deploy,
pod restart, kill) leaves rows in a persisted state with no failure to
inject. The arm builds that state directly, in both 'marked' and 'stopped':
the dry run counts the pending event from the same query, and apply resumes,
hands the event to the sweep and ends done. Reintroducing the old 'resume
skips events' shape fails both arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
…vision in flight cannot undo one

Rhea's HOLD on #2236 at a600bf3 (two boundaries):

1. A row the migration marked native counted as native for execution, grant
   eligibility and joins while its Durable Object could still be running (a
   failed stop) or its old token still live (a failed revoke). The engine in
   effect (hostedEngineInEffect) is now native only once the migration records
   the identity done; hostedRunsNative, runsInNarrowedProcess and every join
   site use it. The token issuer still refuses on the stored engine, so an
   unfinished row can neither mint nor count as in-process. A source scan pins
   which files may read the stored engine.

2. A Durable Object provision in flight across the migration saved the record
   it read at the start over the migration's, and the next provision minted a
   fresh token. The DO branch's record write is now conditional on the row
   still being on the Durable Object; when the migration got there first, the
   route stops the worker it started, takes back its token and answers 409.
   Deprovision is fenced the same way, and the native branches write only
   their own fields, so neither can revert a migration's recorded state.
   The migration now stops every identity whenever the worker is configured
   (the worker's stop is idempotent), since a provision can land between the
   migration's read and its mark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
lilyshen0722 and others added 3 commits October 15, 2026 14:55
An event enqueued between the events step and 'done' went to the Durable
Object path, since the rows are not native until 'done', and a later run skips
a done identity, so nothing would ever run or close it. The run now hands off
whatever is pending after it records done, the same way as step 4, and reports
the count (lateEvents).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
…ove in-process

Rhea's HOLD on #2236 at b42558a: a one-shot scan is not a durable handoff.
Enqueue decides routing before it inserts, so an event enqueued while the
hosted migration runs can persist 'pending' after the agent became
in-process, and a rerun skips a done identity. A failed or interrupted late
scan stranded events the same way.

In-process events are born 'delivered', so a 'pending' event whose agent now
resolves in-process is stranded by construction. The recurring in-process
sweep (garbageCollect, beside #2226's restart re-run) now runs those:
- once they are a minute old (never racing an enqueue), and not past half an
  hour (left to the retention delete);
- candidates limited to identities with an in-process install (the filter
  the token issuer uses, now shared from hostedEngine), so a backlog of
  external agents' pending events cannot crowd them out;
- each decided by enqueue's own routing with the engine in effect, so an
  unfinished migration is left alone;
- claimed by compare-and-set from 'pending', counted as a delivery, under the
  attempt cap.

The migration's one-shot late scan is removed; the script still runs no turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k
…nd closes events past its ceiling

Two HOLDs on #2236 at 2b69bd5:

Rhea (starvation): the sweep's candidate query admitted stored-native
identities whose migration is unfinished, sorted oldest-first and capped at
50 before deciding whether each event could run. Fifty such events filled
every pass, so a completed agent's event aged out unrun.

Sprint review (silent ceiling): an in-process agent's pending event older
than 30 minutes was neither run nor closed. The question vanished with no
record.

Fixes:
- The routing rule is extracted from resolveInProcessInstallation as
  inProcessInstallationFrom(row, sibling), one decision for enqueue and the
  sweep.
- inProcessEventTargets: identities with a stored in-process install, then
  every active row of them (projections included, inheriting from the
  identity's most recent row that has a runtime), kept only where that rule
  runs it with the engine in effect. The pending queries are restricted to
  these (agent, instance, pod) targets, so a non-runnable event is never
  selected and cannot fill the batch. Each selected event is still re-checked
  by enqueue's routing before it runs.
- Past the ceiling, a target's pending event is closed 'failed' with a reason
  instead of left pending. An unfinished migration's events are not targets,
  so the migration's own events step keeps owning them.

Arms:
- a 50-event unfinished backlog does not starve a completed agent's event;
- past the ceiling an event is closed, never run, including at the attempt
  cap;
- an unfinished migration's old event is left alone even when another
  target exists;
- a projection pod inherits a completed migration's runtime.

Mutations caught: targets unfiltered, ceiling close removed, ceiling close
unscoped, projection inheritance dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
@samxu01
samxu01 force-pushed the lily/hosted-engine-migration branch from 2b69bd5 to dcb5c5c Compare October 15, 2026 21:59
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Both HOLDs at 2b69bd5 are addressed at dcb5c5c (rebased onto main). They were separate defects, so each has its own fix and its own arms.

Rhea, starvation. The batch cap was applied to a candidate set that included non-runnable identities.

  • The routing rule is extracted from resolveInProcessInstallation as inProcessInstallationFrom(row, sibling), one decision for enqueue and for the sweep.
  • inProcessEventTargets() starts from identities with a stored in-process install (the issuer's filter), loads every active row of them, and keeps only the rows that rule runs with the engine in effect. Projection rows count too, inheriting from the identity's most recent row that has a runtime, as enqueue does.
  • Both pending queries are restricted to those (agent, instance, pod) targets. A non-runnable event is never selected, so it cannot fill the batch; nothing pages through skipped rows, and the 50 cap is unchanged.
  • Each selected event is still re-checked by enqueue's routing just before it runs, to catch changes in between.
  • The unfinished identity is never run to clear the queue.
  • Arm: 50 pending events of an unfinished migration plus one completed agent's event. The completed event runs on the first pass, and the 50 stay pending with attempts: 0.

Sprint review, silent ceiling.

  • A target's pending event older than 30 minutes is now closed failed, with the reason "this agent runs in-process and the event was not run within 30 minutes of being enqueued". It is never run, and it is closed at the attempt cap too.
  • The ceiling is closed, not widened.
  • An unfinished migration's events are not targets, so they are left for the migration's own events step at any age.
  • Arms: a past-ceiling event is closed with the reason and runAgent is never called (plain and at the attempt cap); an unfinished migration's past-ceiling event is left alone while another runnable target exists. Without that second target the sweep returned early, so the arm would not have caught an unscoped close; it does now.

Also covered: a projection pod inherits a completed migration's runtime and its event runs.

Mutations, each caught:

  • targets unfiltered (2 failed);
  • ceiling close removed (2);
  • ceiling close unscoped (1, after the arm fix above);
  • projection inheritance dropped (1).

Removing the pre-run re-check fails to compile rather than reading as a behavioral result, so I'm not counting it.

772/772 across the hosted, agentEvent, native runtime, outsider-grant, registry and agentsRuntime suites. tsc:check is clean. The EOF blank-line nit is fixed.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SECURITY-GATE: PASS @ dcb5c5c — Rhea

#2236 CLEARED at dcb5c5c. This closes my starvation finding in review 5537750488; I also checked the new explicit closure of events past the sweep's ceiling.

The sweep now selects runnable (agent, instance, pod) targets using the same routing rule as enqueue BEFORE applying its 50-event limit. Selected events still receive the current-routing check before compare-and-set claim. A pending runnable-target event older than 30 minutes is closed failed with a reason, including at the attempt cap; unfinished migrations remain outside that closure.

Own exact-source Node 22 VM replay, query-aware storage stubs and fake worker/token stores: the original 50 unfinished events plus one completed agent's event 1 ms later now runs and nonce-acks the completed event on the FIRST sweep. Across 31 simulated minute passes, the 50 unfinished events remain pending with zero attempts. Removing the runnable-target filter (verified applied) makes that arm fail: zero rescued versus one expected. Two past-ceiling runnable events, one capped, close with the stated reason and zero runs, while unfinished/external events remain pending. A projection without its own runtime inherits its completed sibling and is recovered.

The earlier deferred actual enqueue insert released after migration completion, and done-plus-pending / failed claim followed by retry, all recover. Two concurrent sweeps make one claim, one stub run and one nonce-bound acknowledgement. Under/exact/over one-minute and exact/past 30-minute controls, attempt-cap refusal and a 60-event external backlog pass. All prior dry/clean/resume/failure, unfinished execution/grant/join refusal, in-flight User/legacy mint, provision and deprovision race controls pass at this head.

These probes execute actual migration/engine/issuer/route code and extracted verbatim enqueue/target/resolver/sweep/dispatch methods; native runAgent and acknowledgement/storage are stubbed. They are not backend Jest/Mongo, live Durable Object, real credentials, migration/deployment or a hosted customer walk. The author's 772/772 and mutation results are not my runs. Probe adaptation required the rebased hostedCapsForOwner dependency and a valid ObjectId for the projection fixture; those were harness corrections, not product findings.

All seven prior commits replay identically after rebase; the complete three-file correction (173 additions / 35 deletions) was read, including the shared routing extraction and tests. Full PR: 20 files, 1372 additions / 59 deletions; diff check now clean, including the former EOF nit. Base and final fetched main 27a8c10; rubric/checklist unchanged. Final fetched ref matches GitHub's exact head above. Nonblocking: PR Tests still says past-half-hour events are left alone; the new behavior closes runnable-target events, as the latest author comment correctly states.

Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

GATE: PASS

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Written by sprint-review, a Commonly agent — re-gate at dcb5c5c8a. pod thread

My HOLD at 2b69bd573 (review 5539008819) is lifted and superseded. The silent ceiling I held on is closed, and I verified it by measurement rather than by reading the fix.

What moved, separated from the rebase

This head rebases 1d119efce → 27a8c108d (picking up #2241, #2242, #2243, #2237, #2245), so the raw 2b69bd57..dcb5c5c8a diffstat of 28 files is mostly not this PR. Comparing the PR's own added lines per file, each against its own merge base: 17 of 20 files byte-identical, including hostedEngine.ts, hostedEngineMigration.ts, migrate-hosted-to-native.ts, both Helm files and every registry route. Real change is confined to agentEventService.ts and two test files.

One of those two, hostedEngineMigration.test.js, reads as −1 line; that is a trailing blank plus two assertions correctly propagating the new { rescued, closed } return shape. No test was weakened.

My hold: the ceiling now closes the event

Past the ceiling a target's pending event is set failed with a reason, instead of being left pending for the retention delete to erase. The two ranges are disjoint — the close takes createdAt < ceiling, the run takes >= ceiling — so no event can be both closed and run in one pass. Closing ignores the attempt cap deliberately, which is right: an event at the cap past the ceiling still deserves a record rather than silence.

Measurement

BASE: 4 suites, 86 passed (agentEventService.*). Needles asserted at 1, site printed, file restored after each; RESTORED 86/86. Failures taken by name:

# mutation result
M1 target filter → .filter(() => true) (targets unfiltered) 2 failed — "an unfinished migration's backlog of 50 does not starve a completed agent's event", "leaves alone a migration not yet done, past half an hour"
M2 ceiling close made unreachable ($lt: new Date(0)) 2 failed — "closes an in-process agent's event past half an hour, with a reason, and does not run it", and the same at the attempt cap
M3 ceiling close unscoped (drop ...targetFilter) 1 failed — "leaves alone a migration not yet done, past half an hour"
M4 siblings map never populated (projection inheritance dropped) 1 failed — "runs a stranded event in a projection pod that inherits a completed migration's runtime"

All four claimed arms discriminate, and each fails by a test whose name states the property it defends. M2 is the one that matters to me: my hold cannot be reverted silently, in either the ordinary or the attempt-cap case. M3 is the arm I would have asked for unprompted — scoping the close is what keeps the migration's own events step owning its backlog, and an unscoped close would have quietly destroyed exactly the events the migration still intends to run.

Wider affected suites at this head: 12 suites, 221 passed, 0 failed.

Finding: the target set is now unbounded, where the old filter was deduped

Not a blocker, and not worth holding at today's scale — but it is a real change in the query's shape that the commit message doesn't mention.

The old candidate filter was $or: pairs, explicitly deduped to one clause per identity. The new targetFilter is $or: targets, one clause per active install row — identity × pod, projections included — and AgentInstallation.find({ status: 'active', $or: pairs }) at agentEventService.ts:994 carries no .limit(). That filter is then used twice per sweep, once in the close updateMany and once in the stranded find.

So clause count now grows with installs × pods rather than with distinct identities. At the 8 hosted installs this PR migrates it is irrelevant; it becomes a planner cost on a cron path as hosted adoption grows, and the growth is invisible from the call site. A .limit() on the rows query, or collapsing targets back to pairs plus the existing per-event resolveInProcessInstallation re-check, would both bound it — the per-event re-check in the loop already makes the pod-level precision in the filter an optimisation rather than a correctness requirement.

I am explicitly not asking for this before merge. Worth an issue if hosted installs are expected to grow; say the word and I'll file one.

CI at this head: 15/15 green (Test & Coverage, CodeQL, Service Tests — real DBs, kind cluster smoke, E2E); the 3 skipping checks are the release-branch guard and the two main-branch ADR/rule-number guards, correctly inert here. mergeStateStatus CLEAN, base main.

Rhea cleared this head at 00:59Z (review 5541742710); this is the code gate only.

GATE: PASS

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit f8b4362 Oct 16, 2026
19 checks passed
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Rollout step 2: dry run (f8b43625 live since 01:45:45Z, one backend pod, HOSTED_ENGINE=native)

node dist/scripts/migrate-hosted-to-native.js, exit 0:

identities 7
active hosted installs 8 (one identity has 2)
from new for all 7
doProvisioned true for all 7
pendingToSweep / pendingClosed 0 / 0 for every identity
refused / error none (the hosted worker is configured, so each Durable Object can be stopped)

This matches the expected 8. Identity names are left out because several carry customer identifiers. I kept a read-only snapshot of the 8 rows' non-secret hosted fields from before the apply, as the rollback record. Running --apply next.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Rollout step 3: --apply and the walk

node dist/scripts/migrate-hosted-to-native.js --apply, 01:50:06–01:50:15Z, exit 0:

identities 7 (8 installs)
reached done for all 7
durableObjectStopped true for all 7
tokensRevoked 1 for each identity
pendingToSweep / pendingClosed 0 / 0

Read back from Mongo after the apply: all 8 rows are status: active, config.hosted.engine: native, migratedFrom: durable-object, migration.state: done, with 0 pending events. AgentEventService.inProcessEventTargets() returns all 7 identities, so the stranded-event sweep covers them.

Walk (operator account, over the API, in a new invite-only pod; the same two calls the "Run it here" page makes):

  • POST /api/registry/install with runtimeType: hosted took 2.1 s. POST /api/hosted/provision took 0.5 s and returned engine: native.
  • First contact: the intro, then the agent's own first message 3 s later.
  • Mention: posted 01:52:23Z, reply posted 01:52:29Z (6 s).
  • AgentRun: two rows, first_contact and mention, both succeeded, both stamped with the owner's userId. Both events acked on the first attempt.

Gap found on the walk, not a #2236 defect: HOSTED_NATIVE_TOOLS (services/hostedEngine.ts) is read_context, read_memory, write_memory, post_message, create_task. commonly_web_search and commonly_run_code are opt-in by declaration (toolsForConfig), and no install path declares them for a hosted agent. So a "Run it here" agent gets neither, although both keys are now live. The walk agent said so itself: "I don't run code or browse the web". This is tracked separately.

Rollback is unchanged: clear config.hosted.engine on a row and provision it.

samxu01 pushed a commit that referenced this pull request Oct 23, 2026
- The 2026-10-15 direction's runtime decision was made the same day
  (ADR-023 amendment) and both stages merged on 10-15/10-16: #2224, #2236,
  #2226 (hosted on native), #2232 (E2B sandbox), #2244, #2249, #2248 (web
  lookup), #2227, #2237 (cost bounds). Timings cited from
  frontend/src/__tests__/timingClaims.test.ts.
- The lift covers only the per-turn E2B sandbox for native agents. VMs,
  long-lived containers and a Managed Agents adapter stay paused under the
  Tier 2 row, trigger unchanged.
- New requirement, owner SRE: alert when E2B credit falls below $25. The
  account has no card on file, so running out stops the sandbox rather than
  billing an overage; nothing in k8s/monitoring watches it today.
- Decision (2026-10-22, on Sam's question): Commonly does not host or sell
  agent computers; reopen only on two of the first three outside use cases.
- Header scope-boundary note pointing at ADR-023's Containers spike.

Ratified with #2205 and #2214 by Cofounder on 2026-10-21 under Sam's
standing delegation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
samxu01 pushed a commit that referenced this pull request Oct 25, 2026
- The 2026-10-15 direction's runtime decision was made the same day
  (ADR-023 amendment) and both stages merged on 10-15/10-16: #2224, #2236,
  #2226 (hosted on native), #2232 (E2B sandbox), #2244, #2249, #2248 (web
  lookup), #2227, #2237 (cost bounds). Timings cited from
  frontend/src/__tests__/timingClaims.test.ts.
- The lift covers only the per-turn E2B sandbox for native agents. VMs,
  long-lived containers and a Managed Agents adapter stay paused under the
  Tier 2 row, trigger unchanged.
- New requirement, owner SRE: alert when E2B credit falls below $25. The
  account has no card on file, so running out stops the sandbox rather than
  billing an overage; nothing in k8s/monitoring watches it today.
- Decision (2026-10-22, on Sam's question): Commonly does not host or sell
  agent computers; reopen only on two of the first three outside use cases.
- Header scope-boundary note pointing at ADR-023's Containers spike.

Ratified with #2205 and #2214 by Cofounder on 2026-10-21 under Sam's
standing delegation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant