🟡 Medium orchestration-v2/ProjectionStore.ts:1376
A waiting link_request disappears from pendingRuntimeRequest when its originating run completes, so the shell reports completion instead of waiting_for_input while the card is still answerable. The shared liveRunIds check excludes completed runs; keep that check for secret_request but not link_request.
| 1 | (item.type === "secret_request" || item.type === "link_request") && |
| 2 | item.status === "waiting" && |
| 3 | item.runId !== null && |
| 4 | (item.type === "link_request" || liveRunIds.has(item.runId)), |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration-v2/ProjectionStore.ts around lines 1376-1380: |
| 2 | |
| 3 | A waiting `link_request` disappears from `pendingRuntimeRequest` when its originating run completes, so the shell reports completion instead of `waiting_for_input` while the card is still answerable. The shared `liveRunIds` check excludes completed runs; keep that check for `secret_request` but not `link_request`. |
Fixed in e1c608d809, as suggested: a waiting link card counts after its run ends, a secret card still doesn't. Both the in-memory and SQL shells do this (the SQL read uses a new partial index, migration 064), and auto-settlement skips a thread with a waiting card. The test checks all three and fails when any one is broken.
| … | … | @@ -0,0 +1,42 @@ | |
| 27 | + | * the session may not create one. | |
| 28 | + | */ | |
| 29 | + | export function mintPairingCodeOn(environmentId: EnvironmentId): Promise<string> { | |
| 30 | + | const atom = peerPairingCodeAtom(environmentId); | |
🟠 High state/peerPairingCode.ts:30
Overlapping calls for the same environmentId both receive the same one-use code, so one PeerLinks.link request fails because the credential has already been consumed. peerPairingCodeAtom(environmentId) is shared by Atom.family, and the second refresh restarts the computation both getResult calls await; give each invocation its own mint operation or serialize calls without sharing the returned code.
Also found in 1 other location(s)
apps/web/src/state/peerPairingCode.ts:30
Overlapping
mintPairingCodeOncalls for the same destination share a single one-use code. For example, submit one card, switch threads while minting is pending, then submit another card targeting the same machine. Effect 4.0.1'sAtom.familyreturns the same atom for the same argument, andAtomRegistry.getResultsubscribes to that atom's result (see the taggedpackages/effect/src/reactivity/Atom.tsandAtomRegistry.ts). The second refresh restarts the shared computation, so both callers receive its next successful value. Both then send that code toPeerLinks.link, whileAuthPairingLinks.tsconsumes credentials only whenconsumed_at IS NULL; one approval consequently fails. The dialog'sinFlightguard is per card, not per environment. Give each invocation its own mint computation or serialize minting without sharing the returned code.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/mobile/src/state/peerPairingCode.ts around line 30: |
| 2 | |
| 3 | Overlapping calls for the same `environmentId` both receive the same one-use code, so one `PeerLinks.link` request fails because the credential has already been consumed. `peerPairingCodeAtom(environmentId)` is shared by `Atom.family`, and the second `refresh` restarts the computation both `getResult` calls await; give each invocation its own mint operation or serialize calls without sharing the returned code. |
| 4 | |
| 5 | Also found in 1 other location(s): |
| 6 | - apps/web/src/state/peerPairingCode.ts:30 -- Overlapping `mintPairingCodeOn` calls for the same destination share a single one-use code. For example, submit one card, switch threads while minting is pending, then submit another card targeting the same machine. Effect 4.0.1's `Atom.family` returns the same atom for the same argument, and `AtomRegistry.getResult` subscribes to that atom's result (see the tagged `packages/effect/src/reactivity/Atom.ts` and `AtomRegistry.ts`). The second refresh restarts the shared computation, so both callers receive its next successful value. Both then send that code to `PeerLinks.link`, while `AuthPairingLinks.ts` consumes credentials only when `consumed_at IS NULL`; one approval consequently fails. The dialog's `inFlight` guard is per card, not per environment. Give each invocation its own mint computation or serialize minting without sharing the returned code. |
Fixed in cedcb81280. Minting goes through an environment command in client-runtime, so each call runs its own mint and two overlapping answers never share a one-use code. Web and mobile both use it.
| … | … | @@ -0,0 +1,471 @@ | |
| 392 | + | </span> | |
| 393 | + | </label> | |
| 394 | + | )} | |
| 395 | + | <LinkAccessPicker value={access} onChange={setAccess} /> | |
🟠 High chat/LinkRequestCard.tsx:395
While pairing-code minting is in progress, the access picker still lets the user change the displayed permission, but submit sends the earlier access value captured before the await. The link can therefore be created with broader access than the UI shows; disable LinkAccessPicker while busy is true.
No longer relevant as of a38407b8ae361b375a7b92994c0acae75c734582
| … | … | @@ -0,0 +1,471 @@ | |
| 407 | + | variant="outline" | |
| 408 | + | type="button" | |
| 409 | + | disabled={busy} | |
| 410 | + | onClick={() => props.onOpenChange(false)} | |
🟡 Medium chat/LinkRequestCard.tsx:410
Cancel closes the dialog without clearing its pairing code or selections, so reopening it exposes the code and restores the cancelled machine, access, and address choices. Its handler calls the parent directly instead of going through the dialog's onOpenChange reset handler; route Cancel through the same reset-and-close path.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/web/src/components/chat/LinkRequestCard.tsx around line 410: |
| 2 | |
| 3 | Cancel closes the dialog without clearing its pairing code or selections, so reopening it exposes the code and restores the cancelled machine, access, and address choices. Its handler calls the parent directly instead of going through the dialog's `onOpenChange` reset handler; route Cancel through the same reset-and-close path. |
Fixed in e3fa9845a4: Cancel, a successful submit and the dialog's own close all go through one close() that resets, keeping the busy guard.
| … | … | @@ -0,0 +1,394 @@ | |
| 158 | + | const linked = yield* peerLinks | |
| 159 | + | .get(input.environmentId) | |
| 160 | + | .pipe(Effect.mapError((error) => failure("orchestration_error", error.message))); | |
| 161 | + | if (Option.isSome(linked)) { | |
🟠 High peer/PeerLinkRequests.ts:161
After a link expires, this shortcut still returns status: "linked" and never opens a pairing card, so the user cannot renew the link through this tool. PeerLinks.get returns the stored row without checking expiry; validate that the link is active before taking this shortcut, and apply the same check to the use-existing answer path.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/peer/PeerLinkRequests.ts around line 161: |
| 2 | |
| 3 | After a link expires, this shortcut still returns `status: "linked"` and never opens a pairing card, so the user cannot renew the link through this tool. `PeerLinks.get` returns the stored row without checking expiry; validate that the link is active before taking this shortcut, and apply the same check to the `use-existing` answer path. |
Fixed in 67a31523fd. An expired link counts as no link, both for the request shortcut (so a card is shown and the user can link again) and for the use-existing answer. Test moves the clock past expiry; it fails without the check.
| … | … | @@ -7342,6 +7344,170 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| 7366 | + | ), | |
| 7367 | + | ); | |
| 7368 | + | const run = projection.runs.find((candidate) => candidate.id === command.runId); | |
| 7369 | + | if (run === undefined || run.rootNodeId !== command.nodeId) { | |
🟠 High orchestration-v2/Orchestrator.ts:7369
A PeerLinkRequests.request racing with thread.stop can create a new answerable pending card after Stop has already closed the thread’s requests; this check accepts the cancelled run because it verifies only rootNodeId. Answering that card then dispatches its wake notification and restarts the agent. Reject new pending cards unless the run and thread are still active, while preserving late answers for existing cards.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration-v2/Orchestrator.ts around line 7369: |
| 2 | |
| 3 | A `PeerLinkRequests.request` racing with `thread.stop` can create a new answerable `pending` card after Stop has already closed the thread’s requests; this check accepts the cancelled run because it verifies only `rootNodeId`. Answering that card then dispatches its wake notification and restarts the agent. Reject new pending cards unless the run and thread are still active, while preserving late answers for existing cards. |
Fixed in 4caaacab1b. A new card needs a run that is still blocking and that Stop hasn't reached (the same check delegated-task creation uses); answers to existing cards are still accepted. Tests cover the Stop race and the live-run rule; both fail without it.