feat(delivery): CLIENT_UNINSTALL — second delivery type, machine leaves service on the agent's ACK - #2454
feat(delivery): CLIENT_UNINSTALL — second delivery type, machine leaves service on the agent's ACK#2454semen-flamingo wants to merge 2 commits into
Conversation
…es service on the agent's ACK ClientUninstallDeliverySpec behind openframe.delivery.enabled.CLIENT_UNINSTALL; flag off keeps the old JetStream publisher and PENDING_DELETION-at-send byte for byte. On the new path PENDING_DELETION is set in spec.onAcked, so the sweep still sees the machine's real status while retrying; a failure after the ACK hands the machine back as OFFLINE. The agent's own POST /agent/uninstall closes the row through DeliveryTracker.done(seed). DeliverySpec gains targetId(seed) and onAcked; DeliverySeed gains machineId(); DeliveryTracker.complete is renamed to done to match the row status. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnaktPhqs3aeuuUHdXCAjX
🦩 Flamingo Code Review2 finding(s) — 1 action required · 0 recommended · 1 informational Mode: advisory · Checks: Inline comments: 1 new Findings without an inline anchor in this diff
Need another pass? Commits pushed after this review are not reviewed automatically.
Prefer typing? Comment React 👍/👎 on inline comments to teach the reviewer. Started 2026-09-29 13:42 UTC · updated 2026-09-29 13:44 UTC · workflow run |
| @Override | ||
| public boolean markDone(String id, Set<DeliveryStatus> from, Instant finishedAt, Instant expiresAt) { | ||
| Update update = closed(DeliveryStatus.DONE, finishedAt, expiresAt); | ||
| return updateOne(stillIn(id, from), update); | ||
| } |
There was a problem hiding this comment.
🦩 🔴 [error/action_required] markDone(id, from, finishedAt, expiresAt) never rejects a dispatchId-mismatched row, defeating fencing
The new markDone(String id, Set from, Instant finishedAt, Instant expiresAt) overload matches with stillIn(id, from), i.e. any row for this delivery id currently in one of the from statuses — it does not check dispatchId at all. The dispatchId-aware overload (markDone(id, dispatchId, from, finishedAt, expiresAt)) exists specifically to make sure a DONE result only closes the dispatch it actually belongs to (thisDispatch(id, from, dispatchId)); it prevents a stale/duplicate NATS message from an earlier dispatch attempt from completing a delivery row that has since been redispatched with a new dispatchId. The no-dispatchId overload added here is used by AgentUninstallService via DeliveryTracker#done(DeliverySeed) (client-uninstall, no dispatchId known at that call site), but it removes that fencing for that whole path: if the client-uninstall delivery was retried and re-dispatched (new dispatchId, machine came back online and NATS republished), a late/duplicate ACK-less completion signal (or the deregister endpoint being invoked twice) could close a row against the wrong/newer dispatch attempt, since the query only checks id + status, not which physical dispatch actually finished. This is a real behavioural gap introduced by the new overload versus the existing dispatchId-fenced sibling method.
Evidence
@Override
public boolean markDone(String id, Set<DeliveryStatus> from, Instant finishedAt, Instant expiresAt) {
Update update = closed(DeliveryStatus.DONE, finishedAt, expiresAt);
return updateOne(stillIn(id, from), update);
}
🤖 Prompt for AI agents
In openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/delivery/impl/CustomMachineDeliveryRepositoryImpl.java around lines 113-117, address this code-review finding: markDone(id, from, finishedAt, expiresAt) never rejects a dispatchId-mismatched row, defeating fencing.
The new markDone(String id, Set<DeliveryStatus> from, Instant finishedAt, Instant expiresAt) overload matches with stillIn(id, from), i.e. any row for this delivery id currently in one of the `from` statuses — it does not check dispatchId at all. The dispatchId-aware overload (markDone(id, dispatchId, from, finishedAt, expiresAt)) exists specifically to make sure a DONE result only closes the dispatch it actually belongs to (thisDispatch(id, from, dispatchId)); it prevents a stale/duplicate NATS message from an earlier dispatch attempt from completing a delivery row that has since been redispatched with a new dispatchId. The no-dispatchId overload added here is used by AgentUninstallService via DeliveryTracker#done(DeliverySeed) (client-uninstall, no dispatchId known at that call site), but it removes that fencing for that whole path: if the client-uninstall delivery was retried and re-dispatched (new dispatchId, machine came back online and NATS republished), a late/duplicate ACK-less completion signal (or the deregister endpoint being invoked twice) could close a row against the wrong/newer dispatch attempt, since the query only checks id + status, not which physical dispatch actually finished. This is a real behavioural gap introduced by the new overload versus the existing dispatchId-fenced sibling method.
The flagged code:
```
@Override
public boolean markDone(String id, Set<DeliveryStatus> from, Instant finishedAt, Instant expiresAt) {
Update update = closed(DeliveryStatus.DONE, finishedAt, expiresAt);
return updateOne(stillIn(id, from), update);
}
```
Make the minimal change that resolves the finding; do not refactor unrelated code.
confidence: judge 75 · author 45 — react 👍/👎 to teach the reviewer
…ead cancel removed Agent results reach the tracker as the DeliveryRef the agent copied back (acknowledge/done/fail), server-side completions as the DeliverySeed that was dispatched (done). DeliveryTracker.cancel and the repository overload only it used had no callers: the sweep cancels through DeliveryCloser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnaktPhqs3aeuuUHdXCAjX
Second type on the delivery engine after
TOOL_INSTALLATION(#2212). Flagopenframe.delivery.enabled.CLIENT_UNINSTALL, off by default: the old path (ClientUninstallNatsPublisher+PENDING_DELETIONat send time) stays byte for byte.Why the machine status moves
Today
ForceClientUninstallServicesetsPENDING_DELETIONright after publishing. From that momentMachineStatusServiceignores the machine's heartbeats, so for the sweep it is neverONLINEand no retry would ever happen. On the new path:ForceClientUninstallServiceCLIENT_UNINSTALL:openframe-client:{machineId}+ core publish on the same subjectmachine.{id}.client-uninstall; status untouched; a machine already inPENDING_DELETION/DELETEDis skippedACKEDspec.onAckedPENDING_DELETION— the command is on the machine, retries are overPOST /agent/uninstall→AgentUninstallServiceDELETEDas today +deliveryTracker.done(new ClientUninstallDeliverySeed(machineId))closes the rowDONEFAILEDfrom the agent, or 600 s after ACK without a resultspec.onFailedPENDING_DELETION→ back toOFFLINE(next heartbeat setsONLINE), ERROR log +delivery.failedmetric;DELETED→ nothing, the uninstall happened; never acked → nothing to revertEngine changes
DeliverySpec:targetId(seed)— one place of truth for the row key,request()uses it — andonAcked(row). Both hooks are abstract;ToolInstallationDeliverySpecimplements them empty.DeliverySeed.machineId().DeliveryTrackernow takes the two identities the callers actually hold: agent results come in as theDeliveryRefthe agent copied back —acknowledge(ref, machineId),done(ref, machineId),fail(ref, machineId, error), CAS ondispatchId; a completion the server learns outside the result channel comes in as theDeliverySeedthat was dispatched —done(seed), the key resolved through the spec, CAS on status only.completeis renamed todoneto match the row status.acknowledgenotifiesspec.onAckedonce, on the realPENDING → ACKEDtransition only.DeliveryTracker.canceland the repository overload only it used are removed: no callers, the sweep cancels throughDeliveryCloser.markDonewithoutdispatchId.ClientUninstallMessageimplementsDeliveryPayload; thedeliveryblock is@JsonInclude(NON_NULL).Agent contract for this type (Denys)
machine.{id}.delivery.resultwith the command'sdeliveryblock andresult: ACKED.result: FAILED+error.dispatchIdagain →ACKEDagain, do not run the uninstall twice.DONEis optional: the existingPOST /agent/uninstallcloses the row.CLIENT_UNINSTALLdurable stays until the flag is on everywhere, same as for tool installation.Tenant side (separate PR)
Device ACL: subscribe on
machine.*.client-uninstall(today only.inboxis allowed); flagCLIENT_UNINSTALL: falseeverywhere until the agent release.Tests
1 171 green locally: machine-delivery 52, data-mongo-sync 308, data-nats 85, client-core 242, api-service-core 484. New:
ClientUninstallDeliverySpecTest,ForceClientUninstallServiceTest,AgentUninstallServiceTest, plus tracker/repository cases foronAckedanddone(seed).🤖 Generated with Claude Code
https://claude.ai/code/session_01SnaktPhqs3aeuuUHdXCAjX