Skip to content

DeleteNode drain reports "did not drain within 30s" after removing 162 of 161 planned paths — and aborts the rest of an approved operation #3392

Closed

Description

@rbuergi

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

  1. A completed delete is reported as a failure, so a caller cannot distinguish "did not finish" from "finished, and the check could not tell".
  2. 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.
  3. The failure is silent to anything but the activity log: the request node's activityPath still pointed at the previous (preview) activity, so the obvious place to look showed a stale Succeeded.

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 through IoPool.InvokeCore
  • Portal: memex.systemorph.com, 2026-09-06 10:34:31Z → 10:37:40Z

🤖 Filed from Claude Code.

Activity

  1. rbuergi commented on Sep 6, 2026

    @rbuergi
    ContributorAuthor

    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:

    ##STEP## 53/68 delete-space AgenticOffice: absent — nothing to delete
    

    So AgenticOffice was 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 deleted were found with one descendant still present:

    Space first run re-run found
    ReinsurancePractice deleted (208 drained) 1 descendant(s) → deleted
    Reinsurance deleted (328 drained) 1 descendant(s) → deleted
    Underwriting deleted (1394 in 36 chunks) 1 descendant(s) → deleted
    RiskTransfer deleted (106) 1 descendant(s) → deleted
    ClaimsDeepfield deleted (82) 1 descendant(s) → deleted
    AdvancedBusinessRules deleted (144) 1 descendant(s) → deleted
    AgenticEngineering deleted (362) 1 descendant(s) → deleted

    while ReinsuranceDemo, Ifrs17, SST, SwissSolvencyTest, Planning, AgenticBusiness were genuinely absent — nothing to delete.

    So delete-space reported 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 activityPath still pointed at the PREVIOUS (preview) activity, which reads status: Succeeded. The failing run was a different activity that had to be found by listing _Activity children. A caller reading activityPath gets a stale verdict about a run that failed.

    Evidence: activity 0fefe74fd49b485da5077e3526d80917 (Succeeded, 84 messages, 2026-09-06T10:55:23→10:55:50Z) vs 27992cd1d4e244619f94e61050999ea0 (Failed, 79 messages, 10:34:31→10:37:40Z).

  2. rbuergi commented on Sep 6, 2026

    @rbuergi
    ContributorAuthor

    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. RunDeletePass does not compare counts at all:

    var survivorSet = survivors.Where(p => !string.IsNullOrEmpty(p))
        .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase).Remove(rootPath);
    if (survivorSet.IsEmpty)
        return Observable.Return((IReadOnlyList<string>)acc);   // ← drained, done

    So "a completion check satisfied only by removed == planned" is not what is implemented, and
    162 of 161 is not a predicate mis-firing. It is the timeout's progress message, rendered on the
    way out — {done.Count} of {collected.ToDelete.Count} planned path(s). Confusing, but a symptom.

    The actual defect is at MeshExtensions.cs:2752:

    return DeleteSubtreeUntilDrained(...)
        .TimeoutAtStage(budget, () => …)          // TimeoutAtStage == source.Timeout(timeout, …)

    Observable.Timeout is an inter-emission timeout. DeleteSubtreeUntilDrained emits exactly
    once
    , 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:

    • each stage gets its own budget (six separate TimeoutAtStage calls — they share the value,
      not a running clock), and
    • the chunked path is chunked by the caller, so each chunk is a separate DeleteNode call with a
      fresh 30 s.

    So Underwriting (1394 descendants) survived 59 s as 36 calls × 30 s each, while AgenticOffice
    (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.Timeout becomes what it should have been all along: a no-progress watchdog, firing
    only when a pass produces nothing for the budget, never while the drain advances.

    // RunDeletePass, survivors branch — surface progress before recursing
    return Observable.Return((IReadOnlyList<string>)acc)
        .Concat(RunDeletePass(…, pass + 1, acc));
    // call site: the stage now emits per pass, so collapse to the final value
    .TimeoutAtStage(budget, …)
    .TakeLast(1)          // NOT .Last() — that one blocks

    Total time stays bounded, because MaxDeleteDrainPasses = 5 already caps the passes: worst case
    5 × 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:2757 should then read "made no progress for {budget}s" and drop the
    N of M planned ratio that produced 162 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: DeleteSubtreeUntilDrained is
    private static and MeshWeaver.Mesh.Contract grants InternalsVisibleTo only to
    MeshWeaver.Graph.Test. Landing a change to delete semantics with no test that could fail is the
    pattern 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions