Skip to content

.pr_agent_accepted_suggestions

qodo-merge-bot edited this page Nov 3, 2026 · 139 revisions
                     PR 492 (2026-11-02)                    
[correctness] A retried run can leave abort work pending
A retried run can leave abort work pending `_doStreamText` exposes `call.settleAbort` only on its normal return, so a context-exceeded attempt that recursively retries leaves its own abort work outside the hook received by the caller. If the shared signal aborts during that first attempt, `runStreamingAgent` can await the retry's hook and return while the first attempt is still failing its pending row.

Issue description

A generation attempt that retries or throws before returning never exposes its abort-settlement hook, although its abort listener may still be writing the pending row.

Fix Focus Areas

  • backend/lunora/agent/client/stream-text.ts[173-217]
  • backend/lunora/agent/client/start.ts[213-229]

Recommended Fix

Ensure the caller's returned settlement hook also waits for abort work from abandoned attempts. Handle the retry and throw paths without leaving an earlier attempt's listener or pending mutation untracked, and add a test that aborts during a context-exceeded retry.


[correctness] Abort write failures hide in warnings
Abort write failures hide in warnings The abort handler logs a failed pending-row write with `console.warn` and an unstructured message instead of an error-level, structured log. If `finalizeMessage` rejects, the row remains pending for expiry, but the failure is harder to filter and alert on in Workers logs.

Issue description

The abort handler catches pending-row write failures but emits an unstructured warning. Workers logging guidance calls for structured logs and error severity for failures.

Fix Focus Areas

  • backend/lunora/agent/client/start.ts[226-229]

Recommended Fix

Emit an error-level structured log with the failed operation, relevant row identifier, and serialized error details while retaining the catch that settles the promise.


[correctness] Abort completion lacks a regression test
Abort completion lacks a regression test The new `settleAbort` await has no test asserting that the pending-row write completes before `runStreamingAgent` returns. The existing replacement tests check epoch fencing and usage but would not detect a future change that lets this write outlive the run again.

Issue description

The ordering guarantee introduced by awaiting abort settlement has no direct regression assertion. Existing replacement tests do not verify when the pending-row write completes.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[798-803]
  • backend/lunora/chat/execute.interrupted.test.ts[1136-1159]

Recommended Fix

Add a controlled abort test that holds the pending-row finalization open, verifies the run has not returned, then releases the write and checks the run returns with the row finalized.


[correctness] Abort-write failures log as warnings
Abort-write failures log as warnings `startGeneration` catches a failed pending-row mutation and sends the error to `console.warn` rather than an error-severity log. When the mutation rejects in a Worker, the failure is handled but does not appear at the error severity prescribed by the Workers logging rule.

Issue description

A rejected abort-time pending-row mutation is logged with console.warn, making a write failure a warning in Workers logs.

Fix Focus Areas

  • backend/lunora/agent/client/start.ts[226-229]

Recommended Fix

Log this caught failure at error severity with a searchable operation message and retain the original error details.


[correctness] A retried run can leave abort work pending
A retried run can leave abort work pending `_doStreamText` exposes `call.settleAbort` only on its normal return, so a context-exceeded attempt that recursively retries leaves its own abort work outside the hook received by the caller. If the shared signal aborts during that first attempt, `runStreamingAgent` can await the retry's hook and return while the first attempt is still failing its pending row.

Issue description

A generation attempt that retries or throws before returning never exposes its abort-settlement hook, although its abort listener may still be writing the pending row.

Fix Focus Areas

  • backend/lunora/agent/client/stream-text.ts[173-217]
  • backend/lunora/agent/client/start.ts[213-229]

Recommended Fix

Ensure the caller's returned settlement hook also waits for abort work from abandoned attempts. Handle the retry and throw paths without leaving an earlier attempt's listener or pending mutation untracked, and add a test that aborts during a context-exceeded retry.


[correctness] Failed retries can write after shutdown
Failed retries can write after shutdown `_doStreamText` returns the recursive retry promise without awaiting its rejection, so a retry that fails during `startGeneration` bypasses the outer attempt’s `settleAbort()` cleanup. If the original attempt is processing an abort while retry setup rejects, its pending-row write can remain in flight after `streamText` rejects and the run ends.

Issue description

A retry that rejects during setup bypasses settlement of the earlier attempt’s abort work.

Fix Focus Areas

  • backend/lunora/agent/client/stream-text.ts[191-219]

Recommended Fix

Ensure the recursive retry settles the shared abort-work collection on rejection as well as success, without routing a retry rejection back into the context-overflow retry branch. Add a regression test that holds the first attempt’s abort write while retry setup rejects.


[correctness] Stalled aborts keep tool connections open
Stalled aborts keep tool connections open `runStreamingAgent` now awaits `settleAbort()` before calling `closeMCPClients()`, so cleanup cannot start while the abort handler is waiting for an in-flight save or pending-row finalization. If either database operation stalls during a stopped run, the action remains pending and its MCP clients stay open for the duration of the stall.

Issue description

A stalled abort write prevents MCP client cleanup because settlement is awaited first.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[799-803]

Recommended Fix

Start or complete MCP client cleanup without waiting for abort settlement, then await the abort work before the action returns. Preserve cleanup if settlement or closing fails.


[correctness] Abort-write failures log as warnings
Abort-write failures log as warnings `startGeneration` catches a failed pending-row mutation and sends the error to `console.warn` rather than an error-severity log. When the mutation rejects in a Worker, the failure is handled but does not appear at the error severity prescribed by the Workers logging rule.

Issue description

A rejected abort-time pending-row mutation is logged with console.warn, making a write failure a warning in Workers logs.

Fix Focus Areas

  • backend/lunora/agent/client/start.ts[226-229]

Recommended Fix

Log this caught failure at error severity with a searchable operation message and retain the original error details.


[correctness] Abort write failures hide in warnings
Abort write failures hide in warnings The abort handler logs a failed pending-row write with `console.warn` and an unstructured message instead of an error-level, structured log. If `finalizeMessage` rejects, the row remains pending for expiry, but the failure is harder to filter and alert on in Workers logs.

Issue description

The abort handler catches pending-row write failures but emits an unstructured warning. Workers logging guidance calls for structured logs and error severity for failures.

Fix Focus Areas

  • backend/lunora/agent/client/start.ts[226-229]

Recommended Fix

Emit an error-level structured log with the failed operation, relevant row identifier, and serialized error details while retaining the catch that settles the promise.


[correctness] Abort completion lacks a regression test
Abort completion lacks a regression test The new `settleAbort` await has no test asserting that the pending-row write completes before `runStreamingAgent` returns. The existing replacement tests check epoch fencing and usage but would not detect a future change that lets this write outlive the run again.

Issue description

The ordering guarantee introduced by awaiting abort settlement has no direct regression assertion. Existing replacement tests do not verify when the pending-row write completes.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[798-803]
  • backend/lunora/chat/execute.interrupted.test.ts[1136-1159]

Recommended Fix

Add a controlled abort test that holds the pending-row finalization open, verifies the run has not returned, then releases the write and checks the run returns with the row finalized.



                     PR 491 (2026-11-02)                    
[correctness] Unmanaged saves depend on policy reads
Unmanaged saves depend on policy reads `assertSettingsWritable` queries `organizationPolicies` for every nonempty patch from a user with an active organization, even when the patch contains no defined managed setting, contrary to the helper’s documented behavior. A timezone-only save therefore pays for an unnecessary policy lookup and aborts before the settings row is written if that lookup fails.

Issue description

assertSettingsWritable reads the organization policy for nonempty patches containing no defined managed setting. This makes unrelated saves depend on an unnecessary lookup and contradicts the helper’s documented behavior.

Fix Focus Areas

  • backend/lunora/auth/lib/preference-writes.ts[47-53]
  • backend/lunora/auth/lib/organization-policy-locks.test.ts[114-125]

Recommended Fix

Before querying organizationPolicies, check whether fields contains at least one managed key whose value is not undefined, and return immediately if it does not. Add a test asserting that an unmanaged patch performs no policy lookup when an organization ID is present.


[correctness] Unmanaged saves depend on policy reads
Unmanaged saves depend on policy reads `assertSettingsWritable` queries `organizationPolicies` for every nonempty patch from a user with an active organization, even when the patch contains no defined managed setting, contrary to the helper’s documented behavior. A timezone-only save therefore pays for an unnecessary policy lookup and aborts before the settings row is written if that lookup fails.

Issue description

assertSettingsWritable reads the organization policy for nonempty patches containing no defined managed setting. This makes unrelated saves depend on an unnecessary lookup and contradicts the helper’s documented behavior.

Fix Focus Areas

  • backend/lunora/auth/lib/preference-writes.ts[47-53]
  • backend/lunora/auth/lib/organization-policy-locks.test.ts[114-125]

Recommended Fix

Before querying organizationPolicies, check whether fields contains at least one managed key whose value is not undefined, and return immediately if it does not. Add a test asserting that an unmanaged patch performs no policy lookup when an organization ID is present.


[correctness] User-facing saves opt out of the lock check
User-facing saves opt out of the lock check The design makes `organizationId` a required argument and documents `null` as meaning "a writer with no organization (an internal job)". Despite that, several signed-in user mutations hardcode `null`: `setTaskEmailEnabled`, `setLowRiskTrustedServers`, `writeToolPermission`/`writeToolGrant`, `updateBrowserSettings`, `saveCustomProvider` and `deleteCustomProvider`. None of them writes a managed key today. But once a key such as `taskEmailEnabled` or `lowRiskTrustedServers` is added to `MANAGED_SETTINGS`, these paths will skip the policy read and keep accepting writes to the locked key with no error, and nothing at the call site warns the person adding the key.

Issue description

Several authMutation handlers call saveAiUserPreferences / saveUserSettings with a hardcoded null organization. null is documented as being only for internal writers with no organization. This turns off the lock check for user-initiated writes, so the required argument no longer protects anything.

Fix Focus Areas

  • backend/lunora/notifications/task-email.ts[48-48]
  • backend/lunora/chat/tool-permissions.ts[381-381]
  • backend/lunora/chat/tool-permissions.ts[454-454]
  • backend/lunora/chat/tool-permissions.ts[488-488]
  • backend/lunora/chat/functions.ts[3288-3288]
  • backend/lunora/chat/custom-providers.ts[320-320]
  • backend/lunora/chat/custom-providers.ts[349-349]

Recommended Fix

In every handler that runs for a signed-in user, pass ctx.user.activeOrganization?.id ?? null (or context.user...). Thread it through PreferenceCtx helpers such as writeToolPermission / writeToolGrant. Keep a literal null only for true internal writers (e.g. pruneExpiredToolPermissionGrants, secret consumers called from internal flows), and add a short comment at those call sites explaining why.



                     PR 490 (2026-11-02)                    
[correctness] Deleted organizations retain policy rows
Deleted organizations retain policy rows `setOrganizationPolicy` can insert a row using active-organization membership resolved earlier in the request, without checking that the organization still exists when it writes. If an overlapping organization deletion queues and completes its one-time erase before that insert, the new row is left behind with no further erase scheduled.

Issue description

An in-flight first policy write can insert after the deleted organization's queued erase has run, leaving an orphaned policy row.

Fix Focus Areas

  • backend/lunora/auth/organization-policy.ts[29-70]
  • backend/lunora/auth/organization-policy-erase.ts[16-35]
  • backend/lunora/auth.ts[681-684]

Recommended Fix

Coordinate policy writes and organization deletion through a shared, durable ordering or deletion barrier so a write cannot commit after the final erase. Test the interleaving where a write resolves membership, deletion and erasure finish, and the write then attempts to insert.


[correctness] Owners can save silently discarded rules
Owners can save silently discarded rules `parseOrganizationPolicy` uses `JSON.parse`, which keeps only the last occurrence of a repeated object key before the parser checks for unknown keys or wrong types. A document such as `{"schemaVersion":1,"models":{"blocked":["x"]},"models":{"allowed":["a"]}}` therefore passes validation and is stored even though its submitted text contains an unknown `models` rule.

Issue description

Repeated JSON object keys are collapsed by JSON.parse before policy validation, allowing submitted rules or invalid values to disappear without an error.

Fix Focus Areas

  • backend/lunora/auth/lib/organization-policy.ts[153-175]
  • backend/lunora/auth/lib/organization-policy.test.ts[47-64]

Recommended Fix

Use a duplicate-key-aware JSON parser or tokenizer to reject repeated keys at every object depth before validating and canonicalizing the policy. Add tests for repeated top-level, model, and setting-rule keys, including an unknown rule overwritten by a valid one.


[correctness] Concurrent policy writes lose revisions
Concurrent policy writes lose revisions `setOrganizationPolicy` derives the next revision from an earlier read, then patches the global row without checking that the stored revision still matches; Lunora’s global `patch` guards only its own snapshot. When two owners update an existing policy concurrently across shards, both can return the same revision while the later document replaces the earlier one, leaving readers unable to distinguish the writes by revision.

Issue description

Concurrent updates to an existing global policy can read the same revision, write different documents, and both report the same next revision because the earlier read is not guarded by the subsequent patch.

Fix Focus Areas

  • backend/lunora/auth/organization-policy.ts[45-74]

Recommended Fix

Use Lunora’s ctx.db.patchIf to make the existing-row update conditional on the previously read revision, and increment the revision only when that condition holds. If the condition fails, reread and retry or return a conflict. Return and audit only the revision actually written, and add a test that interleaves two updates to an existing row.


[correctness] Failed queue sends leave policies behind
Failed queue sends leave policies behind `queueOrganizationPolicyErase` catches a failed `enqueueJob` call, logs it, and returns without arranging another attempt. If the producer send fails after the organization delete route succeeds, no cleanup message enters the queue, and the policy row remains stored because the hook has no subsequent cleanup path.

Issue description

A failed producer send is only logged after organization deletion, leaving the policy row with no further cleanup attempt. Consumer retries cannot recover a message that was never queued.

Fix Focus Areas

  • backend/lunora/auth/organization-policy-erase.ts[29-35]
  • backend/lunora/auth.ts[681-685]

Recommended Fix

Provide durable recovery for failed producer sends or terminal job delivery, such as a retryable deletion record or a reconciliation job that removes policies whose organizations no longer exist. Do not treat a logged enqueue failure as completed cleanup. Test a rejected enqueue and verify that policy removal is retried after the organization has been deleted.


[correctness] Concurrent owners create duplicate policies
Concurrent owners create duplicate policies `setOrganizationPolicy` reads for an existing row before inserting, but the `organizationId` index is non-unique and Lunora does not combine those global-database operations in an interactive transaction. When owners of the same organization make overlapping first writes through different caller shards, both can insert revision 1, and later `findFirst` reads and rewrites may act on different policy rows.

Issue description

Concurrent first writes can create multiple policy rows for one organization with the same revision because the lookup and insert are not atomic across caller shards and the organization index is non-unique.

Fix Focus Areas

  • backend/lunora/schema.ts[1597-1609]
  • backend/lunora/auth/organization-policy.ts[44-57]

Recommended Fix

Make the organizationId index unique to enforce one stored row per organization. Handle a competing first insert by rereading the existing row and retrying the replacement with the appropriate next revision, or return a conflict. Add a concurrent-owner regression test that verifies one stored row and a coherent revision.



                     PR 481 (2026-11-02)                    
[correctness] Recovered replies miss activity updates
Recovered replies miss activity updates `inspectRun` uses `endPausedRun` for a saved complete answer instead of the normal non-goal completion path that schedules follow-up suggestions and calls `setThreadStatus` with `replied: true`. After this crash window, the thread becomes active without its successful-reply `lastReplyAt` stamp, and no follow-up suggestions are queued.

Issue description

Watchdog-finished non-goal replies do not receive the thread outcome and suggestions of normally completed replies.

Fix Focus Areas

  • backend/lunora/chat/streaming/persistent/interrupted.ts[313-326]
  • backend/lunora/chat/streaming/persistent/library.ts[815-829]
  • backend/lunora/chat/execute.ts[708-720]

Recommended Fix

For a recovered successful non-goal answer, idempotently queue follow-up suggestions and update the thread with a successful-reply timestamp. Preserve the goal handoff's existing ownership of thread status.


[correctness] Interrupted replies can end mid-answer
Interrupted replies can end mid-answer `settleSavedRows` marks any trailing successful assistant row as `finished` after removing empty placeholders, even when that placeholder was created because the saved step will continue. If a provider-executed tool's call and result occupy one assistant row and the worker dies before the next step is saved, `inspectRun` closes the stream instead of resuming the remaining answer.

Issue description

A saved assistant row can precede another model step, but recovery treats it as a finished answer and closes the reply.

Fix Focus Areas

  • backend/lunora/chat/lib/interrupted-run.ts[70-94]
  • backend/lunora/chat/streaming/persistent/interrupted.ts[286-329]

Recommended Fix

Preserve a durable indication that the saved step was going to continue, such as its pending next-step placeholder, when classifying rows. Take the finish path only when the saved step was final; add a recovery test that dies after a continuing provider-tool step.


[correctness] Old failures can cancel goal reviews
Old failures can cancel goal reviews `failRun` detects a replaced epoch only when `addChunk` throws `RUN_REPLACED`; if the replacement has already closed the stream, its initial status read skips that guarded write and still runs goal cleanup. When an old worker's error reaches this path after a resumed goal reply finishes, `endGoalsForThread` can stop the goal before its queued review runs.

Issue description

An old worker can skip the only epoch-checked write when a replacement has closed the stream, then perform failure cleanup on the replacement's completed reply.

Fix Focus Areas

  • backend/lunora/chat/lib/agent-run.ts[1350-1439]
  • backend/lunora/chat/streaming/persistent/library.ts[194-216]

Recommended Fix

Make the failure path verify ownership before cleanup even when the stream is already done. Fence any fallback status mutation in its own transaction against the expected epoch, and add a test in which an old worker fails after its replacement finishes a goal reply.


[correctness] Recovered answers lose tool labels
Recovered answers lose tool labels `inspectRun` finishes a saved answer without the `stampSavedToolCalls` operation that normal `finishRun` performs before closing the stream. If earlier saved steps contain MCP tool calls or quick-check auto-approvals, their assistant rows remain without the server/tool labels or auto-approval metadata that the stamping mutation writes.

Issue description

A recovered complete answer closes without stamping the tool-call metadata that normal completion saves.

Fix Focus Areas

  • backend/lunora/chat/streaming/persistent/interrupted.ts[313-326]
  • backend/lunora/chat/lib/agent-run.ts[895-901]

Recommended Fix

Retain the labels and auto-approval information needed to stamp saved assistant rows, then perform the idempotent stamping operation on the watchdog's successful-finish path before closing it.


[correctness] A healthy reply can be resumed or failed while it runs
A healthy reply can be resumed or failed while it runs `runStreamingAgent` calls `startBeat()` only when `registerResumableRun` resolves. If the mutation commits but its response is lost (a timeout or transport error), the catch logs "running without it" and the run carries on without beating, even though `runArgs`, `beatAt` and the watchdog chain were already written. About 3 minutes later `inspectRun` reads the stale `beatAt` as a dead worker. If a tool is marked in flight, it fails the live reply with an error line and sets the thread to `error`. Otherwise it fences the run and queues a resume, which aborts the model call in progress and re-bills it.

Issue description

In runStreamingAgent, startBeat() runs only after registerResumableRun resolves. If that mutation commits but its answer is lost, the run keeps going without a beat while the watchdog chain and runArgs exist. Three minutes later the watchdog resumes or fails the live run.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[486-513]

Recommended Fix

In the non-RUN_REPLACED catch branch, still call startBeat(). beatRun only patches a live, unpaused stream, and its epoch check stops a replaced run, so beating an unregistered stream is harmless. Because the stream may in fact be registered as resumable, also treat the outcome as unknown for tool marking: set resumableRun = true (or have it fall back to reading stream.resumable) so that a failed markToolRun stops the tool instead of letting it run unmarked.


[correctness] A resumed reply can repeat a tool after a hiccup
A resumed reply can repeat a tool after a hiccup `runStreamingAgent` leaves its local `resumableRun` false when `registerResumableRun` fails, so `createToolRunMarker.begin` can let a tool run unmarked after a non-CONFLICT `markToolRun` failure even though the stream remains resumable. On a resumed invocation, the earlier `resumable` value and `runArgs` remain stored and the watchdog is active, so a worker death can send the run through recovery and execute that tool again.

Issue description

A failed registerResumableRun on a resumed invocation leaves the local resumableRun false while the persisted stream remains resumable and watched. A subsequent tool-mark failure can therefore allow an unmarked tool action that recovery may repeat.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[185-201]
  • backend/lunora/chat/execute.ts[486-513]
  • backend/lunora/chat/lib/mark-tool-runs.ts[51-77]
  • backend/lunora/chat/streaming/persistent/library.ts[977-1001]

Recommended Fix

Do not allow a tool to run unmarked unless recovery is confirmed disabled on the persisted stream. Initialize resumableRun from resume !== undefined, since a resumed stream was previously registered as resumable, or set it to true when registration fails with an unknown outcome in the non-RUN_REPLACED catch branch; either approach makes a failed tool mark stop execution. If retaining the non-resumable fallback instead, atomically disable watchdog recovery before allowing an unmarked tool to run.


[correctness] Recovered answers never update memory
Recovered answers never update memory `inspectRun` closes an already-saved answer through `endPausedRun` without invoking the normal `recordRun` and `afterRun` post-processing. When the worker dies between saving its final row and completing the run, enabled memory extraction, thread summaries, source annotations and reply timing are skipped.

Issue description

A watchdog-closed successful answer bypasses the post-run work performed by a normally completed worker.

Fix Focus Areas

  • backend/lunora/chat/streaming/persistent/interrupted.ts[313-326]
  • backend/lunora/chat/execute.ts[697-705]
  • backend/lunora/chat/lib/agent-run.ts[531-594]

Recommended Fix

Persist the context needed for applicable post-run tasks before generation and schedule those tasks idempotently when the watchdog finishes a saved answer, preserving the normal eligibility checks for memory and summaries.


[correctness] Tools fail on a hiccup even when no resume is possible
Tools fail on a hiccup even when no resume is possible `runStreamingAgent` wraps every tool with `toolRuns.wrap(...)` before deciding whether the run is resumable. As a result, `markToolRun` runs before every tool call, and when that write fails `createToolRunMarker.begin` rethrows the error, so the tool never runs. Stateless runs, users who turned off "Continue interrupted replies", and runs whose `registerResumableRun` failed are never resumed by the watchdog, yet a busy shard or timeout on this extra write still turns their tool calls into errors. Before this PR those runs never made this write at all.

Issue description

runStreamingAgent wraps every tool with the tool-run marker, even for runs that can never be resumed: stateless threads, users who opted out (resumable: false), and runs whose registerResumableRun failed. For these runs a failed markToolRun write blocks the tool call, and every call adds two writes to the stream row, with no benefit.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[372-376]
  • backend/lunora/chat/execute.ts[482-507]
  • backend/lunora/chat/lib/mark-tool-runs.ts[25-60]

Recommended Fix

Give createToolRunMarker an enabled flag (or a mutable armed state) that is set only after registerResumableRun succeeds and returns true with resumable true. While the marker is not armed, begin/release should do nothing and the wrapped execute should call the original directly. Keep the wrapping where it is (tools are built before registration), but make marking depend on a successful registration. For a resume whose registration fails, keep marking, because its watchdog chain already exists.


[correctness] Interrupted goal replies skip review
Interrupted goal replies skip review `inspectRun` closes a stream with a saved final assistant row through `endPausedRun`, bypassing the normal completion path's `handOffToGoalReview`. If a goal run's worker dies after saving that row but before completing the run, no review job is queued and the goal remains active without its next round.

Issue description

A watchdog-closed reply can belong to an active goal, but closing its stream alone skips the review that advances that goal.

Fix Focus Areas

  • backend/lunora/chat/streaming/persistent/interrupted.ts[295-299]
  • backend/lunora/chat/streaming/persistent/library.ts[815-835]
  • backend/lunora/chat/goals/execute.ts[23-52]

Recommended Fix

Make recovery of a saved final answer perform an idempotent, durable goal-review handoff as well as closing the stream. Ensure a failure to queue the review cannot leave the goal active with no watchdog or follow-up job.


[correctness] Hung replies no longer time out
Hung replies no longer time out `beatRun` (driven by a `setInterval` in `runStreamingAgent`, independent of any model or tool progress) now moves `expiresAt` to `now + STREAM_TTL_MS` on every beat, and `registerResumableRun`/`inspectRun` do the same. A run that is alive but stuck — a stalled provider stream or a tool call that never resolves — keeps beating, so `cleanupExpiredStreams` never moves it to `timeout` and the thread stays running until the user stops it manually.

Issue description

The liveness beat is time-driven and extends the stream's expiresAt on every tick, so a run that is alive but stuck (stalled model stream, never-resolving tool) is never expired by cleanupExpiredStreams; the 20-minute safety net is gone.

Fix Focus Areas

  • backend/lunora/chat/streaming/persistent/interrupted.ts[81-84]
  • backend/lunora/chat/streaming/persistent/interrupted.ts[51-57]
  • backend/lunora/chat/execute.ts[172-184]

Recommended Fix

Only extend expiresAt far enough to cover the watchdog window (e.g. max(expiresAt, now + RUN_STALE_MS + 2 * RUN_WATCH_MS)) and bound the extension by a per-invocation ceiling derived from runClaimedAt (e.g. never past runClaimedAt + STREAM_TTL_MS plus the watchdog margin), or tie the beat to real progress (chunks/step saves) instead of a bare timer, so a stuck run still times out.


[correctness] A slow old run can still save a duplicate step
A slow old run can still save a duplicate step `beforeStepSave` checks the run epoch in a separate mutation before `call.save` writes the step’s rows, and that save does not check ownership. If `inspectRun` fences the old worker and captures the rows for a resume between those calls, the old worker can still save an assistant or tool row that the resume does not account for, potentially causing the step to run again and appear twice in the reply.

Issue description

The step-save fence runs before, rather than within, the mutation that saves step messages. A watchdog replacement can occur between the check and the write, allowing an old run to add rows after the resume has captured its starting rows.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[519-535]
  • backend/lunora/agent/client/stream-text.ts[135-144]
  • backend/lunora/agent/client/start.ts[273-310]
  • backend/lunora/agent/messages.ts[300-337]
  • backend/lunora/chat/lib/run-epoch.ts[27-42]

Recommended Fix

Carry the run hold ({ epoch, streamId }) through call.save() into the addMessages mutation. Call assertRunHeld inside that mutation before inserting or patching step rows, as recordPendingToolApprovals does. Keep beforeStepSave as an early-exit optimization, and retain an optional path for callers without a persistent run epoch.


[correctness] A brief backend hiccup now fails the whole reply
A brief backend hiccup now fails the whole reply `runStreamingAgent` awaits `registerResumableRun` with no catch. That mutation only sets up crash recovery, but any error other than `RUN_REPLACED` (a busy shard, a timeout, an over-long `runArgs` value) goes to the outer catch, which calls `failRun` with `threadStatus: "error"`. So a reply that would have streamed fine before this PR now ends in an error before its first model call, on every first run and every resume of a non-stateless reply.

Issue description

registerResumableRun is awaited in runStreamingAgent with no error handling. A transient failure goes to the outer catch and fails the reply with an error, even though the mutation only enables crash recovery.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[478-491]

Recommended Fix

Wrap the registerResumableRun call in try/catch. If isRunReplaced(error), call onReplaced() and return. For any other error, log a warning (streamLogger.warn) and go on without resume protection: skip startBeat() unless the run is a resume, whose watchdog chain already exists. This matches how createToolRunMarker.begin handles shard blips.


[correctness] Watchdogs stop after scheduler failures
Watchdogs stop after scheduler failures `registerResumableRun` schedules the initial watchdog only when `stream.runArgs` is unset, while `watchRun` schedules its successor only after `inspectRun` and `failRun` succeed; neither schedule supplies a retry policy. If a watch invocation throws or Lunora exhausts its default five retries during an outage, the chain ends, and later registrations cannot restart it because `runArgs` is already set, leaving a subsequent worker death to the 20-minute timeout.

Issue description

The per-stream watchdog depends on a single chain of scheduled actions. An exception in watchRun or exhausted dispatch retries can end that chain permanently, because later registrations do not schedule another watchdog once runArgs is set.

Fix Focus Areas

  • backend/lunora/chat/streaming/persistent/interrupted.ts[38-46]
  • backend/lunora/chat/streaming/persistent/interrupted-watch.ts[17-37]

Recommended Fix

Supply Lunora’s retry: { forever: true } option on both the initial and recurring watchdog schedules, with a bounded retry delay appropriate to the stream expiry, and ensure recovery remains possible after an outage lasting beyond that expiry. Also preserve the chain when inspectRun or failRun throws: catch and log the error in watchRun and schedule the next watch, or track a last-watch timestamp so registerResumableRun and beatRun can restart a stale watchdog.


[correctness] Failed heartbeats are discarded without a log
Failed heartbeats are discarded without a log `beatOnce` catches every `beatRun` failure and only reacts to `RUN_REPLACED`; any other error (a busy shard, a timeout) is discarded with no log line. When three such beats fail in a row, the watchdog decides the live run is dead, replaces it and re-runs its model call. The worker's logs then show only "taken over by a newer run" and nothing about the failed beats that caused it.

Issue description

beatOnce in runStreamingAgent swallows every beatRun failure except RUN_REPLACED. When enough beats fail, the watchdog treats the live run as dead and replaces it, but the worker's logs never say why.

Fix Focus Areas

  • backend/lunora/chat/execute.ts[173-182]

Recommended Fix

In the .catch of beatOnce, after the isRunReplaced branch, add streamLogger.warn([BgAgent] Stream ${streamId}: beat failed, error). Optionally keep a counter of consecutive failures (reset on success) and include it in the message, so a run nearing RUN_STALE_MS without a beat is visible.


[correctness] Tool actions can repeat after mark failures
Tool actions can repeat after mark failures `createToolRunMarker.begin` logs and suppresses non-conflict `markToolRun` failures, allowing `execute` to run without a confirmed in-flight mark. If the write did not take effect and the worker dies before saving the tool-result row, the watchdog sees neither an open tool nor a saved result and can resume by executing the same call again.

Issue description

A failed initial marker write can leave a tool unmarked while it executes, allowing an interrupted run to execute the same call again.

Fix Focus Areas

  • backend/lunora/chat/lib/mark-tool-runs.ts[32-48]
  • backend/lunora/chat/lib/mark-tool-runs.ts[80-88]
  • backend/lunora/chat/streaming/persistent/interrupted.ts[95-113]

Recommended Fix

Require confirmation that the initial +1 in-flight mark was durably written before invoking execute. Propagate uncertain marker-write failures rather than running the tool, and add a test for an initial marker mutation failure followed by worker interruption.


[correctness] Replaced replies can request tool approval
Replaced replies can request tool approval `finishRun` checks the run epoch in a separate mutation before stamping calls and recording pending approvals, leaving those writes unguarded if ownership changes afterward. If the watchdog queues a resume and its job claims the stream in that interval, the old invocation can create approval records and notify the thread owner even though its final chunk is rejected as replaced.

Issue description

A run can lose ownership after the new epoch check but before its approval and metadata writes.

Fix Focus Areas

  • backend/lunora/chat/lib/agent-run.ts[880-905]
  • backend/lunora/chat/tool-permissions.ts[532-594]

Recommended Fix

Pass the stream ID and run epoch into the side-effecting mutations and verify ownership in the same transaction as each write. Do not rely on an earlier, separate assertion to authorize later approval records or notifications.


[correctness] Complete answers fail if a provider-run tool was used
Complete answers fail if a provider-run tool was used `savedRowsOf` sets `callsTools` whenever an assistant row's content has any `tool-call` part. `settleSavedRows` then returns `tool_call_without_result` when the last row is such an assistant row. Tools the provider runs itself (web search and similar) keep both the call and its result inside the assistant row, so the reply's final row matches this check. If the worker dies after that final step is saved but before `finishRun` closes the stream, the watchdog runs `failRun` with an error line and `threadStatus: "error"` on an answer that is already complete, when it should take the `finish` path and close the stream.

[correctness] Completed tools can run twice after a crash
Completed tools can run twice after a crash `markToolRuns` clears the in-flight mark as soon as a tool returns, before the agent saves that step's tool-result row. If the worker dies between those events, `inspectRun` sees neither an open tool mark nor a saved result and can resume by executing the already-completed tool again.

[correctness] Watchdog scans grow with chat history
Watchdog scans grow with chat history `savedRowsOf` collects and sorts every qualifying same-thread message from the prompt onward, rather than limiting the read to this run’s rows. Because `inspectRun` calls it before checking the heartbeat, each minute-long watchdog check of a healthy run repeats a read that grows as the thread accumulates messages.

[correctness] Failed interrupted replies lose skill and time zone
Failed interrupted replies lose skill and time zone `inspectRun` copies `skill` and `timeZone` into `pausedRun` only from an earlier `stream.pausedRun`, and that object exists only after a rate-limit pause. If a run that never paused for a rate limit dies and is then failed (by the watchdog, or after a resume through `before.skill`/`before.timeZone` in `execute.ts`), its usage record is saved with no skill and no time zone, so it lands in the wrong skill and day buckets.

[correctness] Billed steps go unrecorded when resume is refused
Billed steps go unrecorded when resume is refused `inspectRun` builds `pausedRun.replyRowIds` only from the earlier `stream.pausedRun` when `settleSavedRows` returns `ok: false`, leaving out successful rows saved by the dead invocation. If a later row is unsettled or a tool call lacks a result, `recordPausedRunUsage` records only the earlier rows before the paused state is cleared, omitting model steps the dead worker completed and the gateway already billed.

[correctness] Another reply's rows get counted as the dead reply's
Another reply's rows get counted as the dead reply's `savedRowsOf` selects rows by thread, order and creation time rather than by the interrupted reply’s stream or branch, allowing newer runs’ rows to become its resume anchor and count toward its steps. When a new message, regenerate, edit or goal round starts another stream on the same thread, a pending newer row can make `settleSavedRows` fail the interrupted reply, while completed newer rows can enter `pausedRun.replyRowIds`, `stepsUsed` and usage recording even if the later supersession check prevents resumption.

[correctness] A dead old reply can mark a newer chat as errored
A dead old reply can mark a newer chat as errored `inspectRun` decides `fail` before calling `isSuperseded`, and the watchdog then calls `failRun(..., threadStatus: "error")`. `failRun` patches the thread status without checking whether another persistent stream is live on it. So if a reply's worker dies while a newer reply is streaming on the same thread, the watchdog sets the thread to `error` while that newer run is still writing. Any refusal reason, including `unsettled_row` caused by the newer run's own pending rows, reaches this path.

[correctness] A slow but live reply can be run twice at once
A slow but live reply can be run twice at once `inspectRun` treats a stale heartbeat as proof that a run has stopped and queues a resume without revoking the original invocation’s ability to write or beat, while `execute.ts` silently drops `beatRun` failures. When three beats fail or are delayed by contention, a backend hiccup, or a blocked event loop, `claimRunResume` clears the pause and a second `runStreamingAgent` can generate into the same stream and thread alongside the original, interleaving writes while later beats may conceal another interruption.

[correctness] Late deaths in a stream still just time out
Late deaths in a stream still just time out `beatRun` and `registerResumableRun` update `beatAt` but never push back `expiresAt`, which stays 20 minutes from stream creation or the last claim. If a run dies within about the last 3–4 minutes of that window (3 minutes of staleness plus up to 1 minute of watchdog delay), `cleanupExpiredStreams` moves it to `timeout` before `inspectRun` sees a stale beat, contrary to the 'well inside the stream's 20 minute expiry' comment on `RUN_STALE_MS`.

[correctness] A reply that already finished can get a second answer
A reply that already finished can get a second answer `settleSavedRows` treats a trailing successful assistant row with no tool call as a resume point, and `planInterruption` returns `resume`. If the worker dies after the final step is saved but before `finishRun` closes the stream, the resume starts a new model call with `promptMessageId` set to the finished answer. That appends an extra, billed reply after it, when it should just close the stream.


                     PR 480 (2026-11-02)                    
[correctness] Linux agents lose a configured variable
Linux agents lose a configured variable `agentLaunch` unconditionally runs `unset PWD`, removing a value explicitly supplied in the agent's spawn plan. On Linux when `/bin/sh` is available, a globally or per-agent configured `PWD` reaches the wrapper but not the agent, unlike with the previous direct spawn.

Issue description

The Linux shell wrapper removes an explicitly configured PWD from the agent environment while trying to discard a value added by the shell.

Fix Focus Areas

  • apps/desktop/src/acp/process.ts[110-128]
  • apps/desktop/src/acp/process.ts[239-249]
  • apps/desktop/src/acp/process.priority.test.ts[93-110]

Recommended Fix

Pass the spawn plan's environment into launch construction. If the plan supplied PWD, preserve its value and restore it before executing the agent, passing the value as a separate argument rather than interpolating it into shell code. If the plan did not supply PWD, remove the shell-added value. Test both cases.


[correctness] Agents fail to start when priority reads fail
Agents fail to start when priority reads fail `hostPriority()` calls `getPriority(0)` without a fallback, before `spawnAgent` reaches its spawn error handling. If a container denies that priority query while permitting process creation, the agent never starts even though lowering its priority is intended to be best effort.

Issue description

A denied getPriority(0) call prevents an otherwise runnable agent from starting.

Fix Focus Areas

  • apps/desktop/src/acp/process.ts[137-145]
  • apps/desktop/src/acp/process.ts[190-195]

Recommended Fix

Handle priority-read failures as best effort. Use a safe fallback or disable inline niceness adjustment when the host niceness cannot be determined, while still spawning the agent.


[correctness] Dash-prefixed commands fail to start
Dash-prefixed commands fail to start `agentLaunch` places the configured executable immediately after `nice -n DELTA` without ending `nice`’s option parsing. When a permitted command name begins with `-`, the inline priority path passes it to `nice` as an option rather than starting that executable; the earlier direct spawn had no such parsing layer.

Issue description

A configured executable beginning with - is parsed as an option by the new nice wrapper instead of being run.

Fix Focus Areas

  • apps/desktop/src/acp/process.ts[110-118]
  • apps/desktop/src/acp/process.priority.test.ts[93-126]

Recommended Fix

Terminate nice option parsing before the executable on supported platforms, or use a safe fallback for leading-dash commands. Add a launch test using a leading-dash executable name.


[correctness] Priority tests fail for already-nice hosts
Priority tests fail for already-nice hosts Both priority setters request an absolute niceness of 10, while the desktop and Rust tests assert that the agent’s niceness strictly exceeds the host’s. When a desktop or server host starts at niceness 10 or higher, the agent can remain tied with the host—or, if the setter is privileged, run at a higher priority—and both tests can fail at that boundary.

Issue description

A fixed niceness of 10 cannot place the agent below a host already running at niceness 10 or higher, and the strict-greater test assertions fail at that boundary.

Fix Focus Areas

  • packages/device-core/src/agent_host.rs[58-77]
  • packages/device-core/src/agent_host.rs[772-789]
  • apps/desktop/src/acp/process.ts[66-80]
  • apps/desktop/src/acp/process.priority.test.ts[32-52]

Recommended Fix

Choose an agent niceness of at least 10 that exceeds the host’s when the platform permits it, capped at 19. Compute any needed host value before the Rust pre-exec closure. Keep the assertions for the agent’s and its child’s expected effective niceness, but require a strict increase only when one is possible; apply the same condition to the desktop grandchild assertion and account for hosts already at niceness 19.


[correctness] Stopping can hide surviving agent tools
Stopping can hide surviving agent tools `signalGroup()` suppresses every `EPERM` as if the process group were gone, although the comment describes only a narrower macOS exited-leader case. If an agent changes credentials or leaves a descendant owned by another user, `stop()` can ignore both denied signals and keep waiting for exit, while `killNow()` can return without disclosing that a descendant remains.

Issue description

signalGroup() treats genuine permission failures as evidence that an agent’s process group is gone. This can leave a live agent or descendant unsignaled without reporting the failure.

Fix Focus Areas

  • apps/desktop/src/acp/process.ts[170-181]
  • apps/desktop/src/acp/process.ts[248-272]

Recommended Fix

Do not suppress EPERM unconditionally. Propagate denied signals from the live-agent stop path, and handle the macOS exited-leader cleanup case separately only when a condition establishes that the group is gone.


[correctness] Restricted containers fail score tests
Restricted containers fail score tests The Linux score tests require the agent's score to reach 500, although both new priority setters deliberately ignore a refused write to `/proc`. In a container with a read-only or otherwise unwritable score file, the agent still starts as intended but the desktop and Rust tests fail.

Issue description

The Linux tests assume score adjustment succeeds in every environment despite the implementation's best-effort behavior.

Fix Focus Areas

  • apps/desktop/src/acp/process.priority.test.ts[56-69]
  • packages/device-core/src/agent_host.rs[791-813]

Recommended Fix

Make the Linux assertions conditional on whether the test environment permits increasing a child process's score, or isolate the setter behind an injectable filesystem boundary and test successful and refused writes separately. Preserve the assertion that the host's score is unchanged.


[correctness] Early agent children keep full priority
Early agent children keep full priority `spawnAgent` calls `lowerAgentPriority` only after `spawn` has started the agent, so the agent can fork before either scheduling setting is applied. A shell or tool launched during that window inherits the desktop’s original niceness and out-of-memory score, and changing the agent afterward does not change that child.

Issue description

The desktop changes an agent’s priority after starting it, leaving a window in which children can inherit the desktop’s settings.

Fix Focus Areas

  • apps/desktop/src/acp/process.ts[105-129]

Recommended Fix

Start the agent through a child-side launcher that applies both settings before executing the requested command. Preserve the existing environment, arguments, pipes, and process-group behavior, and keep launch best-effort when a setting is denied.


[correctness] Pre-protected hosts lose OOM preference
Pre-protected hosts lose OOM preference Both Linux paths write the absolute out-of-memory adjustment `500` without accounting for a higher value inherited from the host. If the host starts above `500`, an unprivileged write leaves the agent at the host’s score, while a permitted downward write makes the agent less preferred for termination than the host.

Issue description

Writing a fixed out-of-memory adjustment can leave an agent equal to, or below, a host whose inherited adjustment exceeds 500.

Fix Focus Areas

  • packages/device-core/src/agent_host.rs[63-96]
  • apps/desktop/src/acp/process.ts[69-90]

Recommended Fix

Determine the inherited adjustment and never write a lower value. Where possible, choose a value above the host’s, capped at the kernel maximum; keep the child-side Rust operation free of allocation and locks.



                     PR 479 (2026-11-02)                    
[correctness] Restored context can overflow the target
Restored context can overflow the target `historyFits` stops counting at a portable divider, although a later delta divider can replace that portable handoff with an older handoff restored for the returning model. When the older handoff falls outside the recent-row window, the fit check can approve a full switch based on the smaller portable handoff while the next reply receives the larger restored context.

Issue description

A portable divider can end the fit check even though a later delta divider restores an older, larger handoff for the target model.

Fix Focus Areas

  • backend/lunora/chat/agent-switch.ts[378-389]
  • backend/lunora/agent/handoff-source.ts[187-212]
  • backend/lunora/agent/handoff-context.ts[47-108]

Recommended Fix

Make the fit check account for the handoff blocks and rows that context assembly will emit, including bases restored for orphan delta dividers. Test a recent window containing both a portable divider and a later delta whose earlier base is outside that window.


[correctness] Full switches get summarized unnecessarily
Full switches get summarized unnecessarily `historyFits` counts every delta-divider handoff in the recent rows, even though `assembleHandoffContext` can discard an earlier delta handoff when a later delta restores another agent’s saved context. After several switches, a discarded handoff can push the estimate over 75% of the target window while the assembled input fits, causing an explicit full switch to become a portable handoff and potentially incur a summary charge.

Issue description

The full-history fit check counts delta handoffs that reply context assembly later discards, so a fitting full switch can instead become a portable handoff.

Fix Focus Areas

  • backend/lunora/chat/agent-switch.ts[378-388]
  • backend/lunora/agent/handoff-context.ts[82-111]
  • backend/lunora/chat/agent-switch.handoff.test.ts[657-725]

Recommended Fix

Base the fit estimate on the rows and handoff blocks retained by reply-context assembly, while preserving the media estimates and 75% cap. Add a multi-delta regression test where a discarded handoff alone takes the raw count over the cap and verify that an explicit full switch remains full.


[correctness] A model can receive an oversized context
A model can receive an oversized context `historyFits` counts only the latest transcript rows, while context assembly can prepend a stored handoff that those rows do not include. After a portable switch, a short thread can pass the new full-history check even though the stored handoff plus subsequent rows exceed the target's 75% limit.

Issue description

The full-history fit check measures transcript rows but omits stored handoff blocks that are also sent to the target model.

Fix Focus Areas

  • backend/lunora/chat/agent-switch.ts[367-373]
  • backend/lunora/agent/handoff-context.ts[87-114]

Recommended Fix

Base the fit decision on the context assembled for the target, including stored handoff blocks, and choose a portable handoff when that combined context exceeds the target's limit.


[correctness] Image histories overflow small models
Image histories overflow small models `historyFits` uses `pathTokens`, which counts only `row.text`, to decide whether the target can receive full history. Image-only or file-only messages can have no stored text but still reach the model through their saved message content, so a switch can keep history whose actual input exceeds the target's window.

Issue description

The full-history fit check ignores image and file content that the target model still receives. A small-window target can therefore be assigned full history despite exceeding its context budget.

Fix Focus Areas

  • backend/lunora/chat/agent-switch.ts[367-372]
  • backend/lunora/agent/handoff-source.ts[132-164]
  • backend/lunora/chat/agent-switch.handoff.test.ts[600-655]

Recommended Fix

Make the fit check account conservatively for non-text content in the recent messages, falling back to a portable handoff when its cost cannot be estimated safely. Add a switch test with image-only or file-only recent messages.


[correctness] Failed messages hide older reply context
Failed messages hide older reply context `historyFits` limits `pathWindow` to 20 persisted rows without applying the successful-message filter used when a reply loads its recent messages. If failed or pending rows occupy part of those 20 rows, the reply loader can reach older successful messages that the fit check never counts, allowing an oversized history to be kept.

Issue description

The fit check counts its 20-row limit across all statuses, while the reply loader selects 20 successful messages.

Fix Focus Areas

  • backend/lunora/chat/agent-switch.ts[378-389]
  • backend/lunora/agent/handoff-source.ts[177-210]
  • backend/lunora/agent/client/search.ts[119-145]

Recommended Fix

Apply the reply loader's eligibility rules when determining which rows fill the fit-check window, while preserving the divider handling needed for handoff accounting. Test successful messages separated by failed or pending rows, with large successful messages beyond the first 20 persisted rows.


[correctness] Oversized full switches can bypass the cap
Oversized full switches can bypass the cap `modeFor` lets a failed full-history fit check fall through to the delta rule, which tests rows since the target model left rather than the size of the context it will receive. On an explicit full switch back, fewer than 100 intervening rows can select delta even when the target's restored earlier context plus the new handoff exceed the 75% limit.

Issue description

An explicit full request that fails the window check can select delta, which restores unbudgeted earlier context before appending its handoff.

Fix Focus Areas

  • backend/lunora/chat/agent-switch.ts[344-360]
  • backend/lunora/agent/handoff-context.ts[101-114]

Recommended Fix

When a requested full switch fails the fit check, select portable mode rather than falling through to delta, or validate the complete restored delta context against the target window before selecting it.


[correctness] Some threads can no longer switch models
Some threads can no longer switch models `modeFor` now turns an explicit `full` request into a portable handoff whenever `historyFits` is false. Both refusals that handoff can raise (`tooLarge()` in `planHandoff` and `HANDOFF_REFUSED_OVER_BUDGET` in `assertWithinBudget`) still tell the user to "keep the full history" as the way out. Take a thread whose last rows exceed 75% of the target's window and whose newest turn is longer than the tail budget (about 37k characters on a 128k model, about 2.6k at the 1.5k floor). The switch is refused, and retrying with full gets the same refusal. Before this change, the explicit full switch worked.

Issue description

An explicit full request now falls back to a summarised handoff when the history does not fit the target's window. The handoff's refusals (newest turn too long, or over budget) still tell the user to "keep the full history", which now leads to the same refusal. These threads are stuck and cannot switch.

Fix Focus Areas

  • backend/lunora/chat/agent-switch.ts[347-349]
  • backend/lunora/agent/handoff.ts[153-154]
  • backend/lunora/agent/handoff.ts[246-252]

Recommended Fix

Pick one:

  • When the handoff cannot be built and the user explicitly asked for full, honour full (the previous behaviour).
  • Or return a distinct refusal code and message (e.g. "too large for this model; start a new chat") when full is not available, so the advice never points to a mode that cannot be used. Add a test for an explicit full request where the history does not fit and the newest turn is over the tail budget.


                     PR 478 (2026-11-02)                    
[correctness] Later rotations retain old secrets
Later rotations retain old secrets `RotateWebhookSecretDialog` resets `keepOld` only in `onOpenChange`, but a successful rotation closes the mounted dialog by setting the parent’s `rotatingTrigger` to null instead. After an owner opts into overlap once, reopening leaves the checkbox selected, so confirming a later rotation can keep that rotation’s previous secret valid for another 24 hours without a fresh opt-in.

Issue description

A successful rotation closes the dialog through its controlled open prop without resetting the overlap checkbox. The next rotation can therefore reuse an earlier opt-in.

Fix Focus Areas

  • apps/web/src/features/settings/components/chat/rotate-webhook-secret-dialog.tsx[25-38]
  • apps/web/src/features/settings/components/chat/trigger-settings.tsx[621-625]

Recommended Fix

Reset keepOld whenever the controlled open prop becomes false, including after a successful rotation, or remount the dialog with fresh state for each rotation. Verify that reopening it starts with overlap unchecked.


[correctness] Rotated webhooks can run twice
Rotated webhooks can run twice `handleTriggerWebhook` accepts signatures made with either secret during an overlap, but `takeReplayGuard` keys native and timestamped claims on the presented signature. If the same delivery is signed with the old and new secrets, the two valid signatures get different claims and each schedules a run.

Issue description

During an overlap, signing one delivery with each accepted secret bypasses signature-keyed replay deduplication.

Fix Focus Areas

  • backend/lunora/triggers/http.ts[203-210]
  • backend/lunora/triggers/http.ts[414-430]

Recommended Fix

For HMAC schemes, derive the replay claim from the signed delivery bytes rather than the credential, preserving the existing replay-window semantics. Test identical timestamped and native deliveries signed with each secret during an overlap.


[correctness] A second rotation hides an uncopied secret
A second rotation hides an uncopied secret `handleConfirmRotate` replaces the page's single `newWebhookSecret` value on every successful rotation. Because webhook rows remain rotatable while the show-once card is visible, rotating another trigger before copying its first secret removes that secret from the only UI that displays it.

Issue description

A successful rotation overwrites an earlier secret that the owner may not yet have copied.

Fix Focus Areas

  • apps/web/src/features/settings/components/chat/trigger-settings.tsx[613-628]
  • apps/web/src/features/settings/components/chat/trigger-settings.tsx[739-765]
  • apps/web/src/features/settings/components/chat/trigger-settings.tsx[912-922]

Recommended Fix

Prevent another rotation from replacing an undisclosed secret until the owner explicitly dismisses or acknowledges its card, or retain separately identifiable show-once cards for outstanding secrets.



                     PR 477 (2026-11-02)                    
[correctness] Filtered deliveries can run on retry
Filtered deliveries can run on retry `admitTriggerRun` records a retry outcome for a successful charge, but a filtered or disabled refusal can delete the running execution row without recording an outcome. If that refusal commits but its response is lost, `executeTrigger` can retry after settings change, charge and run the same execution ID, and reach `updateExecution`, which silently skips the missing history row.

Issue description

A committed filtered or disabled admission can delete its execution row without preserving a retry outcome. If its response is lost, a retry can re-evaluate changed settings and charge and run the same execution ID without an execution-history row.

Fix Focus Areas

  • backend/lunora/triggers/functions.ts[1171-1175]
  • backend/lunora/triggers/functions.ts[1207-1224]
  • backend/lunora/triggers/functions.ts[1244-1264]
  • backend/lunora/triggers/execute.ts[247-264]

Recommended Fix

Persist admission outcomes, including filtered and skipped refusals, atomically with their effects, and return the recorded outcome when the same execution ID is retried. Within the admission mutation, after checking for an already-successful admission, return a non-running outcome if a supplied execution ID no longer has its running row; do so before evaluating settings, extending the delivery claim, or charging. Add a test that simulates a lost refusal response followed by a filter change.


[correctness] A retried admission can charge a run twice
A retried admission can charge a run twice `executeTrigger` retries `admitTriggerRun` after any mutation error, but admission does not record that an `executionId` has already been charged. If the mutation commits and its response is lost, the retry’s `holdClaim` accepts the same `claimedAt` and reaches `chargeRound` again for one delivery.

Issue description

An admission mutation can commit even when executeTrigger receives an error. Retrying it with the same execution ID can charge the daily quota again for one delivery.

Fix Focus Areas

  • backend/lunora/triggers/execute.ts[237-265]
  • backend/lunora/triggers/functions.ts[1225-1243]

Recommended Fix

Make admission idempotent per execution ID within the admitTriggerRun transaction. Record successful admission on the running execution row, such as with an admittedAt field, and check that record before calling chargeRound on a repeated invocation. Handle the repeated call without charging again while preserving the existing refusal and claim-release behavior. Alternatively, retry only errors known to occur before commit.


[correctness] Delayed webhooks never run
Delayed webhooks never run `takeReplayGuard` gives deliveries a 15-minute claim, but `keepClaim` cannot renew it once it has expired before `onGenerationStart` and gives up after two `extendKey` errors. If queueing or setup takes longer than 15 minutes, or two transient mutation errors occur, `executeTrigger` refuses the run after the HTTP handler has acknowledged the webhook; it does not reschedule the execution or update trigger stats, leaving only a failed execution-history row unless the sender independently redelivers.

Issue description

An acknowledged webhook can wait until its 15-minute claim expires before generation starts, or lose its claim after two transient extension errors. The execution then refuses to run without a retry, while its error message incorrectly suggests that the sender will redeliver it.

Fix Focus Areas

  • backend/lunora/triggers/http.ts[428-434]
  • backend/lunora/lib/claim-once.ts[179-189]
  • backend/lunora/triggers/execute.ts[34-39]
  • backend/lunora/triggers/execute.ts[89-111]
  • backend/lunora/triggers/execute.ts[352-356]

Recommended Fix

Provide a durable recovery path for acknowledged executions that have not reached generation when their claim expires. At generation start, atomically reacquire and extend an expired claim only if no other delivery holds the key, and pass the new fence back to the execution so any later pre-start release targets the reacquired claim; if a redelivery owns the key, let it proceed instead. Retry transient extension errors with backoff or reschedule executeTrigger rather than abandoning the delivery after two attempts. Preserve claim release for deliveries that do not run, replace the ClaimNotKeptError message's promise of automatic redelivery, and surface an unrecoverable refusal to the owner. Add a test in which an acknowledged execution begins after 15 minutes with no redelivery.


[correctness] Delayed webhooks can run twice
Delayed webhooks can run twice `takeReplayGuard` gives native and gitlab-token deliveries a 15-minute claim, but `keepClaim` ignores a false extension result and catches extension errors instead of confirming ownership before generation. If scheduling and setup outlast the claim or the extension mutation fails, the original run can proceed without a long-lived claim while a redelivery acquires the key and runs too; gitlab-token deliveries also lose their former 10-year replay protection.

Issue description

Native and gitlab-token deliveries receive a 15-minute replay claim that may expire before a scheduled run reaches generation. Generation still proceeds if extension returns false or fails, allowing a redelivery to acquire the claim and run the same delivery.

Fix Focus Areas

  • backend/lunora/lib/claim-once.ts[179-189]
  • backend/lunora/triggers/http.ts[428-460]
  • backend/lunora/triggers/execute.ts[76-91]
  • backend/lunora/triggers/execute.ts[329-335]
  • backend/lunora/triggers/functions.ts[1157-1214]

Recommended Fix

Require a successful, fenced claim extension or ownership confirmation before setting started and beginning generation. Allow extendClaim to renew an expired row when its claimedAt still matches, but refuse the run if a redelivery has replaced it; consider confirming ownership during admitTriggerRun in the same transaction as the charge. Retry a transient extension error while the original claim remains valid, and if extension still cannot be confirmed, stop before model work using the existing failure and release handling. Add tests for an expired claim, a replaced claim, and a failed extension mutation.


[correctness] Rotated webhooks run twice
Rotated webhooks run twice `takeReplayGuard` keys a native claim on the presented HMAC rather than the raw body it authenticates. During the 24-hour secret-rotation overlap, the same body signed with either valid secret produces different claim keys, so both deliveries can reach agent-run scheduling.

Issue description

Native replay claims use the presented HMAC, allowing an identical body to acquire two claims and schedule two runs during secret-rotation overlap.

Fix Focus Areas

  • backend/lunora/triggers/http.ts[438-441]
  • backend/lunora/triggers/rotate-secret.test.ts[157-169]

Recommended Fix

Key the long-lived native claim on a digest of the verified raw body's bytes, rather than the presented HMAC, and use a native-specific prefix in the delivery scope. Add a test that enables rotation overlap, submits identical bytes signed under each valid secret, and verifies that only one execution is scheduled.


[correctness] A refused delivery still uses up a daily run
A refused delivery still uses up a daily run `admitTriggerRun` charges a quota round with `chargeRound` before the agent starts, and the new `ClaimNotKeptError` path neither refunds that round nor counts as a run. Each refused delivery burns one of the owner's daily rounds for work that never happened, and the redelivery the message asks for is charged again.

Issue description

A delivery refused because its replay claim could not be kept has already been charged a daily round at admission.

Fix Focus Areas

  • backend/lunora/triggers/execute.ts[393-446]
  • backend/lunora/triggers/functions.ts[1210-1229]

Recommended Fix

Keep the claim before admission (or extend it right after admission and before charging). If you leave the check after admission, refund the charged round when ClaimNotKeptError is caught.


[correctness] Previously delivered webhooks run again
Previously delivered webhooks run again `takeReplayGuard` moves native signatures from the `trigger-webhook` claim key to a `trigger-delivery` key without checking for an unexpired claim under the old scope and key. When a sender redelivers a native webhook during rollout while its old 15-minute claim is still active, the new lookup misses it and the trigger is scheduled again.

Issue description

Native deliveries claimed before deployment retain a 15-minute claim under the old trigger-webhook scope and key. During rollout, the new native path checks only a different scope and key, so a retry can run again.

Fix Focus Areas

  • backend/lunora/triggers/http.ts[417-445]
  • backend/lunora/lib/claim-once.ts[130-166]
  • backend/lunora/triggers/http.test.ts[269-304]

Recommended Fix

Before accepting a native signature under the new key, treat an unexpired legacy trigger-webhook claim as a duplicate. Make the legacy compatibility check and new claim acquisition atomic, and add a test that creates a legacy claim before redelivering through the new handler.


[correctness] Failed deliveries stay blocked for years
Failed deliveries stay blocked for years `takeReplayGuard` now gives native signatures a ten-year claim, but `giveBack` depends on a scheduled release retry if its first release fails. If that retry cannot be scheduled, or the release retries exhaust their six attempts, a delivery that never ran remains a duplicate on every redelivery until the claim expires or is removed manually.

Issue description

Native deliveries that do not run can retain a ten-year replay claim when release scheduling fails or retries are exhausted.

Fix Focus Areas

  • backend/lunora/triggers/http.ts[443-464]
  • backend/lunora/lib/claim-once.ts[295-333]

Recommended Fix

Persist a recoverable release-pending state for non-running deliveries, and arrange for a later recovery pass to release those fenced claims even if the initial retry job cannot be scheduled or exhausts its attempts. Test the failed-release and failed-retry-scheduling paths for a native delivery.



                     PR 476 (2026-11-02)                    
[correctness] Server sandbox text can expose secrets
Server sandbox text can expose secrets `hostLines` copies `host.sandboxDetail` after pattern-based redaction, although the SSH handshake accepts that field as an unrestricted string. A server that supplies an unlabelled short secret such as `hunter2` can have it copied into diagnostics unchanged.

Issue description

The server controls sandboxDetail, and pattern matching cannot guarantee that arbitrary text contains no secrets.

Fix Focus Areas

  • apps/desktop/src/device/diagnostics.ts[124-133]

Recommended Fix

Do not copy the raw sandbox detail. Derive a fixed diagnostic category or status instead, and test with an unlabelled short secret.


[correctness] Copied routes can expose secrets
Copied routes can expose secrets `routeLine` interpolates `host.user` and `host.address` without passing either value through `redact`. When either configurable field contains a valid token-shaped value, that value reaches the clipboard unchanged in server diagnostics that a user copies and shares.

Issue description

The copied server route includes configurable SSH username and address values without secret redaction, allowing a valid token-shaped value in either field to appear in support text.

Fix Focus Areas

  • apps/desktop/src/device/diagnostics.ts[104-105]
  • apps/desktop/src/device/diagnostics.ts[121-125]
  • apps/desktop/src/device/diagnostics.test.ts[94-103]

Recommended Fix

Pass the diagnostics context or home directory into routeLine, then redact the username and address before formatting the route. Add a server-diagnostics test with a valid token-shaped SSH username or address and assert that the token is absent from the copied text.


[correctness] Escaped passwords leave text on clipboard
Escaped passwords leave text on clipboard `SECRET_VALUE` treats an escaped quote as the end of a quoted assignment, so `redact` removes only the prefix of its value. For `password="abc\"hunter2"`, the suffix `hunter2` survives; this also affects copied route values independently of server sandbox text.

Issue description

The quoted-assignment matcher stops at escaped quotes and leaves part of a credential in copied diagnostics.

Fix Focus Areas

  • apps/desktop/src/device/diagnostics.ts[23-26]
  • apps/desktop/src/device/diagnostics.test.ts[157-174]

Recommended Fix

Match escaped characters within quoted values, or conservatively remove the remainder of an ambiguous assignment. Test that escaped quotes leave no credential suffix in both free text and routes.


[correctness] Copied errors can disclose short secrets
Copied errors can disclose short secrets `redact` removes assignment-form secrets and recognizable tokens but leaves an unlabelled short value such as `hunter2` unchanged. SSH or remote-host stderr can become `reconnect.reason`, so a failed connection can put that value into either the server or this-computer diagnostics.

Issue description

Copied diagnostics can include short secrets from unrestricted SSH or remote-host stderr because pattern-based redaction cannot identify unlabelled values.

Fix Focus Areas

  • apps/desktop/src/device/diagnostics.ts[22-48]
  • apps/desktop/src/device/diagnostics.ts[105-107]
  • apps/desktop/src/device/diagnostics.ts[123-126]

Recommended Fix

Do not include raw reconnect reasons in diagnostics presented as safe to paste. Replace them with a constrained, non-sensitive error classification or omit them, and test a failure whose stderr contains an unlabelled short secret.


[correctness] Padded credentials can survive redaction
Padded credentials can survive redaction `LONG_BLOB` requires a word boundary after a character class that includes `=`, but `=` itself is not a word character. A 40-character Base64 value made of 39 unrecognized letters followed by `=` misses the matcher entirely and can remain in a copied route or other diagnostic field.

Issue description

The trailing word boundary prevents a 40-character padded Base64 value from matching the long-blob redaction rule.

Fix Focus Areas

  • apps/desktop/src/device/diagnostics.ts[29-30]
  • apps/desktop/src/device/diagnostics.test.ts[120-128]

Recommended Fix

Use an end condition compatible with every permitted blob character, including =, and add a test for a 39-character prefix followed by Base64 padding.


[correctness] Quoted passwords reach support text
Quoted passwords reach support text `redact` uses `SECRET_ASSIGNMENT` to consume only the first whitespace-delimited word of an assignment value, including the `Basic` scheme in an Authorization header or the first word of a quoted password. When a server error contains `Authorization: Basic dXNlcjpwYXNz` or `password="open sesame"`, the remaining credential text survives redaction and reaches the clipboard through copied diagnostics.

Issue description

redact leaves credential text behind when an assignment contains a quoted value with spaces or an Authorization: Basic credential. That text can be copied with server diagnostics.

Fix Focus Areas

  • apps/desktop/src/device/diagnostics.ts[22-38]
  • apps/desktop/src/device/diagnostics.test.ts[1-128]

Recommended Fix

Update redaction to consume complete quoted assignment values while retaining unquoted-value handling. Also ensure an Authorization: Basic <credential> match consumes the credential rather than just the scheme, either by handling the scheme in SECRET_ASSIGNMENT or by applying BEARER first. Add regression tests for password="open sesame", password: "two words", and Authorization: Basic dXNlcjpwYXNz, asserting that no credential text remains in the output.


[correctness] Windows home paths can remain visible
Windows home paths can remain visible `redact` replaces the exact home string case-sensitively, and its Windows fallback matches only `\Users\` with that capitalization. On Windows, text containing `c:\users\Alice\secret-project` with a home value of `C:\Users\Alice` passes both checks and can appear in copied server names or sandbox text.

Issue description

Case variants of Windows home paths are not shortened before diagnostics are copied.

Fix Focus Areas

  • apps/desktop/src/device/diagnostics.ts[32-53]
  • apps/desktop/src/device/diagnostics.test.ts[120-128]

Recommended Fix

Handle Windows home-path comparison and fallback matching case-insensitively, accounting for separator variants. Test a differently capitalized drive and users directory against the supplied home.


[correctness] Server-reported text bypasses redaction
Server-reported text bypasses redaction `hostLines` interpolates `host.platform` directly, unlike the other server-reported text it passes through `redact`. The SSH handshake accepts that field as an unrestricted string, so a connected server can place paths or other sensitive text verbatim in copied server diagnostics.

Issue description

The server-reported platform field bypasses diagnostics redaction and has no input constraint at the SSH handshake.

Fix Focus Areas

  • apps/desktop/src/device/diagnostics.ts[90-90]

Recommended Fix

Map the reported platform to a small allowlist of known platform labels, using unknown for other values. Test that a platform containing a path or newline is not copied verbatim.


[correctness] Clipboard copy lacks a success test
Clipboard copy lacks a success test The new `index.test.ts` assertion checks that the site cannot invoke `copyDiagnostics`, but never invokes it as the device window or checks the clipboard text. Because the Electron mock also has no `clipboard.writeText`, this test suite cannot exercise the successful copy path for either this computer or a server.

Issue description

The new IPC test covers rejection of a site-window call but does not cover the successful device-window clipboard path.

Fix Focus Areas

  • apps/desktop/src/device/index.test.ts[75-110]
  • apps/desktop/src/device/index.test.ts[377-380]

Recommended Fix

Add a clipboard.writeText spy to the Electron mock. Invoke copyDiagnostics with a trusted device-window event both without an ID and with a listed server ID, and assert that the clipboard receives the appropriate diagnostics text.


[correctness] Terminal handler's comment now sits above the wrong code
Terminal handler's comment now sits above the wrong code The new `copyDiagnostics` handler was put between the comment that explains `openHostTerminal` (connecting first, which asks to trust the server's key) and the `openHostTerminal` handler itself. Anyone reading the file now sees that trust explanation on the diagnostics handler, and the terminal handler is left without it.

Issue description

The copyDiagnostics handler was inserted between the openHostTerminal comment and the openHostTerminal handler.

Fix Focus Areas

  • apps/desktop/src/device/index.ts[857-873]

Recommended Fix

Move the local(LOCAL_CHANNEL.copyDiagnostics, ...) block above the // A shell on a paired server... comment and give it a short comment of its own.



                     PR 472 (2026-11-02)                    
[correctness] Cold typechecks cache incomplete output
Cold typechecks cache incomplete output `backend:lint:types` lists `^build` and `codegen` as sibling prerequisites, so codegen can run before the service SDK emits the declarations it needs. On a clean checkout, codegen can silently infer `unknown` return types and cache that generated output even though the typecheck later waits for both prerequisites.

Issue description

Backend codegen can race workspace package builds, producing and caching degraded generated types on a clean checkout.

Fix Focus Areas

  • backend/project.json[9-33]

Recommended Fix

Make the codegen target itself depend on the required workspace package builds, rather than listing ^build and codegen as independent prerequisites of lint:types. Verify the ordering from a clean checkout with SDK dist/ absent.


[correctness] Backend types silently become unknown
Backend types silently become unknown The backend codegen step is a parallel sibling of the web branch that runs `build:packages`, so it has no dependency on the service SDK’s built `dist` declarations. On a clean checkout, codegen can resolve `@neore/service-sdk` before those declarations exist, infer `unknown` return types, and still exit successfully.

Issue description

Backend codegen runs concurrently with the package build, although it needs the service SDK’s emitted declarations for type inference.

Fix Focus Areas

  • .github/workflows/deploy.yml[226-289]
  • backend/deploy-codegen.test.ts[17-28]

Recommended Fix

Move backend codegen outside and after the parallel block, before Alchemy, or otherwise make completion of build:packages an explicit dependency. Update the test to verify that execution dependency rather than relying only on the textual order of commands.


[correctness] Deployments fail on a clean checkout
Deployments fail on a clean checkout The new `codegen` dependencies in `backend/project.json` run only through Vis, while the deploy workflow invokes Alchemy without generating the now-ignored architecture module. On a clean checkout, Alchemy bundles `backend/src/server.ts`, whose generated app imports that module, so the backend Worker bundle cannot be produced.

Issue description

The deploy workflow bypasses the new Vis codegen dependencies, leaving the backend Worker without its ignored architecture module on a clean checkout.

Fix Focus Areas

  • .github/workflows/deploy.yml[274-281]
  • .github/workflows/deploy.yml[559-560]
  • backend/project.json[24-33]

Recommended Fix

Run the backend codegen package script after checkout and before the Alchemy deploy step. Do not rely on the web build or the Vis-only dependencies to generate the backend module.



                     PR 470 (2026-11-02)                    
[correctness] Queued calls on an outdated computer hang instead of explaining
Queued calls on an outdated computer hang instead of explaining `listPendingDeviceCalls` now leaves out every non-Stop call for a device that `isDeviceOutdated` flags. The relay only claims calls it got from that list, so the new outdated branch in `claimDeviceCall` (which fails the call with `OUTDATED_NOTICE`) runs only in the narrow race between list and claim. A call queued before a manifest marked the computer outdated sits `pending` until its deadline sweeper expires it, and the chat, the model and the ACP turn then get a generic expiry instead of the update notice.

Issue description

listPendingDeviceCalls hides calls for outdated devices. Because the relay only claims calls it listed, the OUTDATED_NOTICE failure path in claimDeviceCall almost never runs. Those calls wait until they expire and then report a generic error.

Fix Focus Areas

  • backend/lunora/devices/functions.ts[415-432]
  • backend/lunora/devices/functions.ts[469-486]
  • backend/lunora/devices/functions.ts[384-388]

Recommended Fix

Pick one approach:

  1. Drop the .filter in listPendingDeviceCalls and let claimDeviceCall fail outdated calls with OUTDATED_NOTICE, which it already does.
  2. When updateDeviceManifest stores a protocol below MIN_DEVICE_PROTOCOL, fail that device's open non-acp_cancel calls right away with OUTDATED_NOTICE. Do this the same way as the claim path: patch the call to failed, apply ACP_CLEARED and call onAcpCallEnded. Add a test: queue a call, send a manifest with a protocol below the floor, then assert the call ends with OUTDATED_NOTICE.

[correctness] Delayed reports blame the computer clock
Delayed reports blame the computer clock `reportDeviceCallProgress` labels every signed report older than the ten-minute freshness window as clock skew before checking whether the call or phase is still current. If a correctly stamped progress event is delayed past that window, the relay shows clock-setting advice instead of treating the report as stale; `updateDeviceManifest` similarly labels an old retransmitted manifest before its rollback check.

Issue description

An old signed timestamp can result from delivery delay or retransmission, not just a wrong device clock. The new clock-skew checks run before existing stale-report and manifest rollback handling.

Fix Focus Areas

  • backend/lunora/devices/functions.ts[362-388]
  • backend/lunora/devices/functions.ts[579-603]
  • backend/lunora/devices/lib/signing.ts[64-85]

Recommended Fix

Apply rollback and terminal or out-of-order report checks before clock diagnosis. Do not infer a slow device clock solely from an old received stamp: reject an ambiguous old message as stale, or obtain a freshly signed device timestamp before giving clock-setting advice. Keep the immediate clock-skew diagnosis for future-dated stamps, which transit delay cannot explain.


[correctness] Starting a worker on an old computer hides the update fix
Starting a worker on an old computer hides the update fix `createAcpCall` now returns `outdated` for `acp_start`, but the sub-agent spawn path in `sub-agents/functions.ts` (`openHostSession` result handling) still uses its own inline ternary instead of `startFailureMessage`, so `outdated` falls into "That computer cannot start an agent right now.". When the lead model dispatches a host-run worker to a computer whose app is below `MIN_DEVICE_PROTOCOL`, neither the model nor the user is told that updating the app is the fix, even though the PR updated every other start path (`remote.ts`, `switch.ts`, guide host) to say so.

Issue description

The sub-agent host spawn in sub-agents/functions.ts maps an openHostSession error with an inline ternary that only knows busy. The new outdated refusal from createAcpCall therefore shows up as "That computer cannot start an agent right now." instead of the update notice.

Fix Focus Areas

  • backend/lunora/sub-agents/functions.ts[810-815]
  • backend/lunora/acp/lib/start.ts[23-29]

Recommended Fix

Import startFailureMessage from ../acp/lib/start and throw new LunoraError("BAD_REQUEST", startFailureMessage(opened.error)). Then add a test that spawns a host worker on a device with hostProtocol below MIN_DEVICE_PROTOCOL and expects OUTDATED_NOTICE.


[correctness] Queued calls reach outdated computers
Queued calls reach outdated computers `createDeviceCall` checks the computer's protocol only when it creates a call, not when the relay later claims it. If a manifest marks the computer outdated after a call was queued, `listPendingDeviceCalls` still returns that call and `claimDeviceCall` marks it claimed without rechecking compatibility.

Issue description

Calls queued before a computer reports an outdated protocol remain claimable after its manifest changes, bypassing the new creation-time gate.

Fix Focus Areas

  • backend/lunora/devices/internal.ts[145-147]
  • backend/lunora/devices/functions.ts[416-478]
  • backend/lunora/devices/lib/acp-call.ts[55-58]

Recommended Fix

Recheck the device's current protocol before claiming a pending call, and withhold incompatible calls from the pending list. Preserve the explicit acp_cancel exception so Stop remains deliverable.


[correctness] Slow delivery gets blamed on the clock
Slow delivery gets blamed on the clock `clock_hint` treats the time between signing and receipt as clock skew, so its two-minute threshold is met whenever a call with the backend’s five-minute lifetime has expired. This affects calls held past their deadline or redelivered after a lapsed claim; the one-second test fixture misses the case, and the incorrect advice reaches the chat and model through `releaseDeviceCall` and `finishHostTurn`.

Issue description

clock_hint gives clock-skew advice for normally timed calls delivered after their five-minute expiry because it compares receipt time with signing time. The existing one-second test does not cover that case.

Fix Focus Areas

  • packages/device-core/src/envelope.rs[138-160]
  • packages/device-core/src/envelope.rs[198-210]
  • packages/device-core/src/envelope.rs[455-468]

Recommended Fix

Do not infer clock skew on the expired-call branch solely from the time elapsed since signing; omit the hint there unless another signal establishes skew. Preserve the hint for the too-far-ahead branch when issued_at - now meets the skew threshold. Update the Rust test to use expiresAt = issuedAt + 300_000 and assert that delivery at expiresAt + 1_000 adds no clock sentence, while retaining coverage for demonstrably skewed calls.


[correctness] Pull-request guide on an old computer says busy
Pull-request guide on an old computer says busy `sendToHostFor` now returns `outcome: "outdated"`, but `beginHostGuide` in `source-control/guide-host.ts` handles it the old way. Its `REPORTED_OUTCOMES` / `vUnavailable` / `unavailableReason` list has no `outdated` entry, so the new outcome falls back to `busy`. Its `.catch()` around `startHostSessionFor` also turns every start refusal, the new `outdated` one included, into `guide_host:busy`. So when someone asks for a pull-request guide on a computer whose app is out of date, the Develop guide tab tells them the computer is busy. They retry a request that cannot succeed, and nothing points them to updating the app.

Issue description

The source-control host-guide flow (beginHostGuide / startHostGuide) turns the new outdated refusal into busy. Users with an out-of-date desktop app are told their computer is busy, not that they need to update.

Fix Focus Areas

  • backend/lunora/source-control/guide-host.ts[41-47]
  • backend/lunora/source-control/guide-host.ts[102-136]
  • backend/lunora/source-control/guide-host.ts[143-162]
  • apps/web/src/features/develop/components/guide-tab.tsx[105-112]

Recommended Fix

  1. Add "outdated" to REPORTED_OUTCOMES, to the vUnavailable.reason union, and to the unavailableReason switch.
  2. Before reserving, check isDeviceOutdated(device) next to the existing isDeviceOnline check and return { reason: "outdated", status: "unavailable" }. Also stop collapsing every startHostSessionFor failure into busy: map an outdated refusal to guide_host:outdated.
  3. Add an outdated message to the guide tab's unavailable-reason record (Lingui), e.g. "Neore on that computer is out of date. Update it, then try again."
  4. Add a test that runs a guide against a device with hostProtocol below MIN_DEVICE_PROTOCOL and expects reason: "outdated".

[correctness] Progress clock errors stay hidden
Progress clock errors stay hidden `DeviceRelay` shows the clock-skew toast for manifest `send(id)` rejections but discards the same `DEVICE_CLOCK_SKEW` error from `reportDeviceCallProgress`. If the computer’s clock becomes skewed while it handles a call, signed progress reports are refused, leaving the user without clock guidance or an updated call phase.

Issue description

The desktop relay shows clock-skew guidance for manifest refusals but silently swallows the same refusal for signed progress reports.

Fix Focus Areas

  • apps/web/src/features/devices/components/device-relay.tsx[164-167]
  • apps/web/src/features/devices/components/device-relay.tsx[177-195]
  • backend/lunora/devices/functions.ts[570-575]

Recommended Fix

Handle DEVICE_CLOCK_SKEW in the progress-report rejection handler using the same deduplicated, actionable toast as manifest refusals. Keep unrelated best-effort progress failures non-disruptive, and verify that a rejected skewed progress report shows the message once.


[correctness] Older computers can remain blocked or allowed
Older computers can remain blocked or allowed `updateDeviceManifest` leaves `hostProtocol` unchanged when a subsequent valid signed manifest omits `protocol`. If a paired computer changes to a build that does not report the field, call eligibility still follows its previous build's value: a previously outdated computer stays blocked, while a previously current one stays allowed.

Issue description

A valid manifest without protocol retains a value reported by an earlier build, so the call gates do not reflect the latest manifest.

Fix Focus Areas

  • backend/lunora/devices/functions.ts[383-392]
  • backend/lunora/devices/functions.test.ts[273-283]

Recommended Fix

Update or clear hostProtocol on every accepted manifest, including when protocol is absent or invalid. Test a second manifest without the field after both a current and an outdated reported protocol.


[correctness] Clock alerts appear only in English
Clock alerts appear only in English `toastClockSkew` displays the backend's English `error.message` directly rather than a localized message. When the backend refuses a manifest or progress report for clock skew, users in other locales receive the English clock guidance.

Issue description

The relay displays a server-generated English clock-skew sentence without localization.

Fix Focus Areas

  • apps/web/src/features/devices/components/device-relay.tsx[24-29]
  • backend/lunora/devices/lib/signing.ts[74-79]

Recommended Fix

Pass structured skew direction and offset information to the client, render a Lingui message with those values in the relay, and extract and translate the message for supported locales.


[correctness] Coding-agent start says nothing about updating
Coding-agent start says nothing about updating `startFailureMessage` has no `outdated` case, so it turns `createAcpCall`’s refusal into a generic cannot-start message. When a host computer’s app is out of date, both agent start and switch show that message instead of an update notice, while the Devices settings Start button remains enabled.

Issue description

Agent start and switch paths turn an outdated refusal into a generic error, and Devices settings still offers a Start action for a computer that requires an update.

Fix Focus Areas

  • backend/lunora/acp/lib/start.ts[15-25]
  • backend/lunora/acp/remote.ts[128-138]
  • backend/lunora/acp/switch.ts[445-458]
  • backend/lunora/devices/lib/acp-call.ts[55-58]
  • apps/web/src/features/devices/components/devices-settings.tsx[88-96]

Recommended Fix

Add an outdated branch to startFailureMessage with wording that tells users to update the Neore app on that computer and try again, so both start and switch explain the refusal. Disable Start for devices whose updateRequired flag is true while retaining their visible update notice. Add a test that starts a host session on a device with hostProtocol below MIN_DEVICE_PROTOCOL.


[correctness] Permission answers on old computers say busy
Permission answers on old computers say busy `answerHostPermission` in acp/remote.ts maps every `createAcpCall` error except `offline` to `{ reason: "busy" }`, so the new `outdated` refusal for `acp_permission_answer` shows up as "computer is busy". The user retries a permission prompt that will never get through until they update. Nothing tells them that updating is the fix.

Issue description

The permission-answer path turns the new outdated refusal into busy.

Fix Focus Areas

  • backend/lunora/acp/remote.ts[215-275]
  • apps/web/src/features/chat/host/host-composer.tsx[130-148]

Recommended Fix

Add v.literal("outdated") to the answerHostPermission output reason union. Map call.error === "outdated" to { reason: "outdated" }. In host-composer.tsx, show the localized "Neore on your computer is out of date" message for it.


[correctness] Invalid versions still receive calls
Invalid versions still receive calls `parseProtocol` returns `undefined` for a present but invalid `protocol`, and `updateDeviceManifest` then stores the manifest without a new protocol value. When a computer first reports its version as a string, fraction, or out-of-range number, the later outdated check treats it like a legacy manifest with no version and can hand it calls instead of refusing the malformed report.

Issue description

A malformed but present signed protocol value is treated as an absent legacy value, allowing the outdated-device gate to fail open.

Fix Focus Areas

  • backend/lunora/devices/functions.ts[339-341]
  • backend/lunora/devices/functions.ts[383-392]

Recommended Fix

Preserve compatibility when protocol is absent, but reject a manifest when the field is present and is not a valid supported integer. Add tests that distinguish missing from malformed protocol values.


[correctness] Any protocol bump cuts off all older desktops
Any protocol bump cuts off all older desktops `constants.test.ts` requires `MIN_DEVICE_PROTOCOL` to equal `PROTOCOL_VERSION` in host.rs. That constant is the desktop↔SSH-host wire version, and it gets bumped for host-only features such as new request types. The next bump fails CI unless the backend floor is raised too, and once that backend deploys, every desktop still on the previous build lists as `updateRequired` and gets no tool or coding-agent calls. This happens even though the backend envelope format did not change.

Issue description

The test forces the backend's minimum protocol to equal the Rust host protocol, so every bump of that constant immediately locks out all older desktops.

Fix Focus Areas

  • backend/lunora/devices/lib/constants.test.ts[9-13]
  • backend/lunora/devices/lib/constants.ts[60-65]

Recommended Fix

Parse PROTOCOL_VERSION from host.rs and assert MIN_DEVICE_PROTOCOL <= version. That keeps the guarantee that no current build is called outdated, while letting the floor stay at the oldest protocol the backend really supports. Note in the constant's doc that raising it is a deliberate cut-off decision.



Clone this wiki locally