Repository navigation
Description
What happened
An approved 68-step delete operation on the systemorph portal (memex.systemorph.com) aborted at step 53/68 because a delete-space reported a timeout for work it had actually finished.
##STEP## 53/68 delete-space AgenticOffice: FAILED — Delete of 'AgenticOffice' exceeded 30s
timeout in stage 'commit': [DeleteNode:commit] the bottom-up delete of 'AgenticOffice' did not
drain within 30s — 162 of 161 planned path(s) were already removed from storage
🚨 162 of 161. The message says, in the same breath, that the drain did not complete and that more paths were removed than were planned. Removing every planned path is the completion condition; the predicate reported failure anyway.
Steps 1–52 had succeeded. The throw aborted the remaining 15 steps, so an operation a human had explicitly approved stopped half-done with no retry, and the request node went to state: Failed.
Why the count can exceed the plan — and why that must not be a failure
The planned count is a snapshot taken before the drain. The bottom-up delete then removes descendants; anything created, or any satellite materialised, between the snapshot and the drain makes the actual removals exceed the plan. That is normal in a live mesh. A completion check that is satisfied only by removed == planned — or that keeps waiting while removed > planned — cannot terminate in exactly the case where the work is most certainly done.
Evidence that the work completed
Measured after the failure:
AgenticOffice/_GitSync |
gone (step 51 deleted it) |
| the drain's own report | 162 of 161 planned paths already removed from storage |
Why the size argument does not hold
It is not a "too big" case — the opposite:
| Space | descendants | path | outcome |
|---|---|---|---|
Underwriting |
1394 | drained in 36 chunks of ≤500 | ✅ deleted, ~59 s |
Ifrs17 |
467 | one call | ✅ deleted |
AgenticEngineering |
362 | one call | ✅ deleted |
AgenticOffice |
106 | one call | ❌ 30 s timeout |
The largest Space in the run succeeded because chunking gave it progress checkpoints. A 106-node Space on the single-call path hit a fixed 30 s cap. So the cap is not sized against the work; the chunked path and the single-call path have different completion semantics.
Impact
- A completed delete is reported as a failure, so a caller cannot distinguish "did not finish" from "finished, and the check could not tell".
- It aborts the whole operation. 15 approved steps did not run. Re-running is safe here only because this particular script is idempotent (already-deleted steps log
absent — nothing to delete) — that is a property of the script, not a guarantee of the API. - The failure is silent to anything but the activity log: the request node's
activityPathstill pointed at the previous (preview) activity, so the obvious place to look showed a staleSucceeded.
What I am NOT claiming
I have not read DeleteNode's commit stage, so I have not established whether the defect is the predicate, the 30 s cap, the snapshot, or the interaction of all three. The 162 of 161 line is the observation; the mechanism is a hypothesis. Raising the timeout would be a band-aid — the question is why a drain that removed every planned path reports that it did not drain.
Repro
Delete a Space of a few hundred nodes through the single-call (non-chunked) path while the mesh is under concurrent write load, and compare the drain's removed against planned.
Evidence
- Activity:
rbuergi/Requests/remove-reinsurance-and-education/_Activity/27992cd1d4e244619f94e61050999ea0(status: Failed,messageCount: 79,maxSeverity: Error, ended 2026-09-06T10:37:40.689Z) - Stack:
OperationPlan.DeleteAt→OperationPlan.Drain→OperationPlan.Complete, raised throughIoPool.InvokeCore - Portal: memex.systemorph.com, 2026-09-06 10:34:31Z → 10:37:40Z
🤖 Filed from Claude Code.
Activity
rbuergi commentedon Sep 6, 2026
Confirmed: the failed delete had in fact completed
The operation was re-run after a fresh preview. Step 53 — the exact step that threw — reported:
So
AgenticOfficewas already fully gone when the first run declared it "did not drain within 30s". That closes the question the original report left open: this is a false negative in the completion check, not a partial delete. The run then completed##DONE## 68 step(s),status: Succeeded.A second, quieter symptom in the same evidence
On the re-run, seven Spaces the first run had logged as
deletedwere found with one descendant still present:ReinsurancePracticedeleted(208 drained)1 descendant(s)→ deletedReinsurancedeleted(328 drained)1 descendant(s)→ deletedUnderwritingdeleted(1394 in 36 chunks)1 descendant(s)→ deletedRiskTransferdeleted(106)1 descendant(s)→ deletedClaimsDeepfielddeleted(82)1 descendant(s)→ deletedAdvancedBusinessRulesdeleted(144)1 descendant(s)→ deletedAgenticEngineeringdeleted(362)1 descendant(s)→ deletedwhile
ReinsuranceDemo,Ifrs17,SST,SwissSolvencyTest,Planning,AgenticBusinesswere genuinelyabsent — nothing to delete.So
delete-spacereported success while leaving one node behind, roughly half the time — the mirror image of the reported defect: the same completion check that refuses to admit a finished delete also admits an unfinished one. I have not established what that residual node is (root? a satellite created during the drain?), and I am not claiming the two share a cause — but they are the same predicate and the evidence for both came from one run pair.That also means a single run of this operation would have left seven Spaces partially deleted while reporting success, and nothing would have said so.
Method note
The first run's failure was nearly missed: the request node's
activityPathstill pointed at the PREVIOUS (preview) activity, which readsstatus: Succeeded. The failing run was a different activity that had to be found by listing_Activitychildren. A caller readingactivityPathgets a stale verdict about a run that failed.Evidence: activity
0fefe74fd49b485da5077e3526d80917(Succeeded, 84 messages, 2026-09-06T10:55:23→10:55:50Z) vs27992cd1d4e244619f94e61050999ea0(Failed, 79 messages, 10:34:31→10:37:40Z).rbuergi commentedon Sep 6, 2026
The completion check is not the defect — the timeout is. Read before fixing.
Chased this into the code, because the write-up above would send a fixer at the wrong line.
The completion predicate is already correct.
RunDeletePassdoes not compare counts at all:So "a completion check satisfied only by
removed == planned" is not what is implemented, and162 of 161is not a predicate mis-firing. It is the timeout's progress message, rendered on theway out —
{done.Count} of {collected.ToDelete.Count} planned path(s). Confusing, but a symptom.The actual defect is at
MeshExtensions.cs:2752:Observable.Timeoutis an inter-emission timeout.DeleteSubtreeUntilDrainedemits exactlyonce, at the very end of the whole multi-pass drain — so on a single-emission source that
degenerates into a total-duration cap. A drain that is removing nodes steadily is killed at 30 s
purely for taking 30 s.
That also explains the asymmetry the report found puzzling, and it is not about size:
budget(six separateTimeoutAtStagecalls — they share the value,not a running clock), and
DeleteNodecall with afresh 30 s.
So
Underwriting(1394 descendants) survived 59 s as 36 calls × 30 s each, whileAgenticOffice(106) died as one call needing several passes. Whether an operation fits is decided by how the
caller chunked it, not by how much work there is.
The fix shape — and explicitly NOT raising the bound
Make the drain emit once per pass instead of once at the end. Then the existing
Observable.Timeoutbecomes what it should have been all along: a no-progress watchdog, firingonly when a pass produces nothing for the budget, never while the drain advances.
Total time stays bounded, because
MaxDeleteDrainPasses = 5already caps the passes: worst case5 × budget, and a genuinely stuck subtree still fails loudly with the existing
"could not drain the subtree after N pass(es)". The message at
MeshExtensions.cs:2757should then read "made no progress for {budget}s" and drop theN of M plannedratio that produced162 of 161.Why I am not landing it
This is the recursive-delete path, and the surrounding code carries a dense incident history —
#1198 (stage-tagged budgets), #1128 (caller authorization evaporating mid-commit), #2477 (two hubs,
two duties), plus the tombstone resurrect-race. Changing when it gives up deserves a test that
drives a multi-pass drain, and the seam for that does not exist yet:
DeleteSubtreeUntilDrainedisprivate staticandMeshWeaver.Mesh.ContractgrantsInternalsVisibleToonly toMeshWeaver.Graph.Test. Landing a change to delete semantics with no test that could fail is thepattern I would flag in review, so I am not doing it here.
Whoever picks it up: the change is small, the bound is already there, and the correction that matters
is that the predicate is fine and the timeout is measuring the wrong quantity.
🤖 Filed from Claude Code.