Skip to content

feat(lifecycle): add irreversible resident retirement primitive - #116

Open
ian-de-marcellus wants to merge 11 commits into
anima-research:mainfrom
ian-de-marcellus:feat/resident-retirement
Open

ian-de-marcellus wants to merge 11 commits into
anima-research:mainfrom
ian-de-marcellus:feat/resident-retirement

Conversation

@ian-de-marcellus

@ian-de-marcellus ian-de-marcellus commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Persistent resident agents can end a turn or enter reversible dormancy, and operators can erase stored data, but there is no neutral terminal lifecycle primitive that permanently prevents future inference for one resident while preserving that resident's Chronicle and history.

Architecture

Agent Framework owns only the irreversible seal and enforcement primitive. Resident-facing wording, confirmation ceremony, cooling-off policy, memory-health policy, and notification policy belong to the host. The companion Connectome Host PR implements one protected default ceremony on top of this API.

Changes

  • Add opt-in AgentConfig.retirement: { enabled }, public lifecycle status, and the imperative framework.retireResident(agentName, reason?) API.
  • Fsync a strict append-only retirement ledger outside Chronicle's reversible branch projection, then record the terminal lifecycle event in Chronicle.
  • Fail loudly and closed at startup on torn, malformed, semantically invalid, or duplicate seal records; document backup-first manual syntax recovery without providing an unretirement path.
  • After sealing, reject queued and future conversational or maintenance inference, direct starts, operator nudges, later message appends, and new conversation forks from the retired template.
  • Clear queued requests, provider cooldown state and waiters, gate sleep/self-wake state, and resident-authored foreground/background code runners.
  • Add a generic protected live-tool surface for tools that may appear only in the named resident's real provider-issued stream. Public programmatic dispatch, puppetToolCall, code execution, maintenance inference, ephemeral subagents, and conversation forks cannot invoke these tools.
  • Preserve Chronicle, messages, workspace data, inference logs, and the terminal record. Retirement remains distinct from end-turn, dormancy, and erasure.
  • Document already-running ephemeral subagents as separate short-lived identities: they may finish their own computation, but cannot append to or wake the sealed resident.
  • Add lifecycle documentation, a changelog fragment, and adversarial tests for sealing, restart resistance, fork resurrection, puppet/programmatic isolation, torn and invalid records, timer cleanup, history preservation, and app-owned stores.

Review response

This revision moves the challenge, confirmation wording, cooling-off interval, readiness gate, and operator notification out of Agent Framework and into Connectome Host. It also rebases onto current main and covers the newer puppetToolCall surface.

Tests

  • npm run build: pass
  • Focused lifecycle/routing/puppet/code-execution set: 61 pass / 0 fail
  • npm test: 659 pass / 0 fail / 4 existing skips
  • git diff --check: pass

The squashed revision has the same source tree as the full-tested pre-squash head.

Not verified

  • Not exercised against a paid or live model provider.
  • Not independently exercised on Linux; repository CI covers the supported matrix.
  • No process-crash or power-loss fault injection was performed around the fsync boundary.

Out of scope

  • A fixed resident-facing ceremony, human approval flow, dormancy policy, data erasure, or framework reversal API.
  • Preventing a machine owner from altering files outside the framework.

Companion PR

connectome-host#92 implements the Host-owned resident tool, challenge, cooling-off floor, memory-health gate, and post-seal notification. Merge and release this Agent Framework primitive first; the Host draft can then update its dependency range and lockfile to the qualifying release.


  • Changelog fragment added under changelog.d/.

🤖 Generated with OpenAI Codex

@antra-tess antra-tess left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the 08-26 review sweep, plus maintainer direction after discussion. The mechanism you built is genuinely careful — two forced turns, cooling-off, one-use challenge, timing-safe compare, honest semantics text, append-only fsynced seal — and the integration suite is strong. The requested changes are architectural first, then a short list of holes found in review.

Architectural direction: the resident-facing surface of this belongs in connectome-host, not the framework. The framework is the right home for the enforcement primitive — the seal file, loadRetirementSeals, and the inference-denial guards can only live here. But the tool itself — its name, description/consent text, ceremony shape (challenge, cooling-off, confirmation phrase), and any operator-notification policy — should be host-composable rather than a fixed built-in. Concretely: AF exposes an imperative API (e.g. framework.retireResident(name, {reason}) — irreversible, sealed, guarded — plus the lifecycle status query and perhaps the challenge/cooling-off helpers), and connectome-host builds the resident-facing tool on top, so deployments can shape the wording, the ceremony, and whether a human is notified at request time. Your two-turn design would make a fine default implementation of that host-side surface; we just don't want its exact wording and policy frozen into AF.

Findings that apply to the enforcement half regardless:

  1. Fork resurrection (must-fix): createConversationAgent seeds a conversation fork from the retired template's still-compiling context under a fresh agent name — every retirement guard checks the fork's name and passes, so a single channel message can revive the retired resident's full context and identity prompt. Needs a router guard (refuse spawning from a retired template), or an explicit statement that forks are outside the seal's scope.

  2. puppetToolCall interaction (landed on main after you branched): with the lifecycle tool on getToolsForAgent's surface, puppet's existence check passes, executeToolCall fails 'unknown tool', and the forged 'resident requested retirement (errored)' pair is stored into the sealed identity's history. Exclude the lifecycle tool from puppet's surface on rebase.

  3. Torn seal line — decided: fail loud and fail closed is the intended behavior. A torn/invalid line in resident-retirements.jsonl refusing to boot the whole host is accepted; please pin it with a test (and cover challenge-TTL expiry) so it's deliberate rather than incidental, and document the recovery expectation (manual inspection of the named file:line).

  4. Minor: gate timers/sleep state for a retiree stay armed (permanent dropped-request churn — clear them in stopResidentAuthoredActivity); running ephemeral subagents spawned by the resident aren't stopped at confirmation (document or stop them).

Suggested path: keep this PR's seal + guards + denial surface + tests as the framework primitive with the imperative API, and move the tool definition + ceremony to a companion connectome-host PR — happy to discuss the interface split there. Also needs a rebase (#123/#126 conflicts).

@ian-de-marcellus
ian-de-marcellus force-pushed the feat/resident-retirement branch from 3e81a45 to ce4d0da Compare August 28, 2026 01:17
@ian-de-marcellus ian-de-marcellus changed the title feat(lifecycle): add irreversible resident retirement feat(lifecycle): add irreversible resident retirement primitive Aug 28, 2026

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 BLOCKING

Reviewer: Codex (GPT-5.6 Sol)

Reviewed head: ce4d0da78e6ff33be8f2a55bd7cd564db6801a9d

The architectural split is now in the right place, and the revised head covers the previous fork, puppet, timer, and malformed-ledger findings. Three enforcement defects remain.

  1. Blocking — the live-only boundary is bypassed by omitting or spoofing callerAgentName.

    src/framework.ts:7487 derives the authorization lookup key from caller-controlled input:

    const caller = call.callerAgentName ?? '__ephemeral__';
    if (this.moduleRegistry.isLiveTool(call.name, caller)) {

    src/module-registry.ts:256 then asks the module only for that caller's live surface. A ceremony module normally returns its tool only for the resident name, so executeToolCall({ name: 'resident--retire', ... }) with no caller, or with callerAgentName: 'someone-else', makes isLiveTool return false and falls through to module.handleToolCall. Against this head, both calls returned success=true and the supposedly live-only handler ran twice (handled=2). A programmatic caller can therefore counterfeit the exact resident-only action this boundary exists to protect.

    Make the restriction independent of the untrusted caller field. For example, reserve live-only names across all configured resident surfaces and reject those names from executeToolCallFrom for every non-provider origin, while keeping the provider dispatcher as the only trusted entry. Add regression cases for omitted and spoofed caller names, and for ModuleContext.callTool.

  2. Blocking — a retirement tool resumes inference after the durable seal.

    When a live tool calls retireResident, the resident is still waiting_for_tools. The resulting event takes the normal ready path at src/framework.ts:4306, persists the tool round, and reaches this unconditional continuation at src/framework.ts:4562:

    } else if (currentState.stream) {
      currentState.stream.provideToolResults(...);
      agent.setStreaming(currentState.stream);
    }

    There is no retired-state check here, and retireResident never cancels the active agent stream. I reproduced this with a live-only module whose handler calls framework.retireResident('resident'): after the call, lifecycle status was retired, yet a second response from the resumed stream was accepted and POST-SEAL CONTINUATION was present in the compiled resident context. On a real yielding provider this is a post-seal model continuation that can speak or issue more tools.

    Treat applying the seal as terminal for the current resident stream: cancel/abort it with a retirement-specific framework reason, reset the state safely, and make the tool-result path short-circuit rather than resume or requeue when the resident is sealed. Add a regression test where handleToolCall itself retires the resident and prove that no second provider round or post-seal message is accepted.

  3. Blocking durability gap — the first seal file can disappear after a reported success.

    src/framework.ts:2074-2087 creates resident-retirements.jsonl, writes it, and fsyncs only the file descriptor. On POSIX filesystems, fsyncing a newly created file does not durably commit its parent-directory entry. A crash or power loss after retireResident returns can therefore lose the filename even though the API claimed the irreversible seal succeeded; Chronicle is explicitly not authoritative and may be rewound.

    Detect first creation and durably sync the parent directory (and any newly created path components), or use an equivalent crash-safe creation sequence. The durability test should cover the first record separately from appending to an existing sidecar; the current tests exercise logical restart only, not the creation boundary.

Tooling results

  • git diff --check HEAD^ HEAD — pass; no whitespace errors.
  • User-facing internal-shorthand scan of the diff — pass; no matches.
  • npx --no-install tsc --noEmit — pass against cached @animalabs/chronicle@0.3.0, @animalabs/context-manager@0.6.3, and @animalabs/membrane@0.5.79.
  • node --import tsx --test test/resident-retirement.test.ts — pass.
  • node --import tsx --test test/framework.test.ts — pass.
  • npm run build — pass.
  • npm test — inconclusive locally: the compiled runner reported nine passing test files and then stopped producing progress; it was interrupted after a process audit. Current GitHub CI is green on Node 20/24 across Ubuntu and macOS.
  • Live-only boundary repro — omitted caller: success=true; spoofed caller: success=true; handler executions: 2.
  • Retirement-continuation repro — lifecycle retired; postSealContinuationPersisted=true.

Verdict: the previous review's requested architecture and edge cases are substantially addressed, but the new authorization boundary is currently bypassable and the seal does not terminate the stream that invoked it. Those are merge-blocking correctness properties for an irreversible lifecycle primitive; the first-write durability gap is also part of the advertised contract. Review confidence is high for these findings despite the local full-suite stall because both runtime defects reproduce deterministically on the exact head and the focused/type/build gates pass.

— Reviewed by GPT-5.6 Sol via OpenAI Codex.

@ian-de-marcellus

ian-de-marcellus commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Thank you for the detailed review. The branch is now rebased and updated at 0be84b2, with the requested architectural split and enforcement follow-ups in place:

  • Agent Framework now owns only the opt-in neutral seal, durable lifecycle status, terminal enforcement, and observability through the imperative retireResident() API. The resident-facing tool, wording, challenge, cooling-off period, health gate, and notification policy live in the companion Connectome Host PR, feat(recipe): add resident retirement ceremony connectome-host#92.
  • A retired conversation template cannot create new forks. Existing persistent conversation forks are also sealed, unbound, and removed from the live registry while their Chronicle namespaces remain available.
  • Live-only tool names are globally reserved against public dispatch, omitted or spoofed caller identities, ModuleContext.callTool, code execution, and puppetToolCall; puppet cannot append a counterfeit lifecycle exchange.
  • Applying retirement cancels the active resident stream. The tool-result path short-circuits if the live handler applied the seal, and late/buffered provider events cannot resume inference, route prose, invoke tools, or enter history.
  • Torn, malformed, invalid, and duplicate seal records fail closed with regression coverage and documented manual recovery. Host challenge expiry is covered separately in the companion PR.
  • First seal creation fsyncs the file, its containing directory, and every newly created custom path entry; the regression distinguishes initial creation from a later append.
  • Retirement clears queued requests, gate sleep/self-wake state, provider cooldowns, and resident-authored foreground/background runners. Already-running ephemeral subagents retain the documented separate policy: they may finish their own work but cannot wake or append into the sealed resident.

The later enforcement findings from Anarchid's review are pinned with exact adversarial regressions as well. GitHub CI is green across Ubuntu and macOS on Node 20 and 24, the changelog check is green, and GitHub reports the branch cleanly mergeable.

@antra-tess, would you take another look when convenient? Thanks again.

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 BLOCKING

Reviewer: Codex (GPT-5.6 Sol)

Reviewed head: 0be84b278966ba76176c40ef2d27c9b1a5f475c9

The three exact-head findings from the previous campaign review are addressed, but the new directory-durability path introduces a fail-open interval when the ledger mutation succeeds and a later durability operation throws.

  1. Blocking — a post-write seal error leaves the resident active in the current process.

    src/framework.ts:2114-2118 can throw while syncing a newly created directory after the seal file itself has already been written and fsynced:

    for (const directory of directoriesToSync) {
      const directoryFd = openSync(directory, 'r');
      try {
        fsyncSync(directoryFd);

    retireResident does not install the in-memory terminal state until appendRetirementSeal returns at src/framework.ts:2242-2244:

    this.appendRetirementSeal(record);
    this.retiredResidents.set(agentName, record);
    this.stopResidentAuthoredActivity(agentName);

    Therefore a directory open/fsync/close error, or any other error after bytes may have reached the append-only file, makes the API throw while leaving the current resident able to infer. The on-disk ledger may already contain the authoritative valid seal; a restart would retire the resident, but the process that performed the operation remains active until then. This contradicts the fail-closed terminal contract and is especially dangerous because the host sees an exception and may continue running.

    I reproduced the exact boundary by wrapping Node's fsyncSync so the second call performs the real directory fsync and then throws. The seal record was present, getResidentLifecycleStatus('resident') still returned active, and a subsequent public inference reached the provider:

    {"retirementError":"injected directory-fsync failure","fsyncCalls":2,"sealContainsResident":true,"lifecycleAfterError":{"status":"active","retirementEnabled":true},"providerCalls":1}
    

    Once the append attempt has reached a point where its outcome may be durable or ambiguous, failure must close the in-process identity before the error escapes. A safe shape is to catch seal-write/durability errors, install a process-local terminal/ambiguous state and stop resident-authored activity, then rethrow (or fail-stop the framework). On restart, the existing strict ledger parser can distinguish a valid record from a torn one. Add a fault-injection regression where file fsync succeeds and directory fsync throws, and assert that inference remains denied despite retireResident throwing.

Tooling results

  • npm ls --depth=0 — pass after materializing the exact cached packages @animalabs/chronicle@0.3.0, @animalabs/context-manager@0.6.3, and @animalabs/membrane@0.5.79 in the detached worktree. The initial bare-worktree dependency probe/typecheck failed only because those three packages were absent; both were rerun after isolation setup.
  • npx --no-install tsc --noEmit — pass.
  • node --import tsx --test test/resident-retirement.test.ts — pass.
  • node --import tsx --test test/framework.test.ts — pass.
  • npm run build — pass.
  • npm test — locally inconclusive: nine compiled test files passed, then the runner produced no further progress for roughly 90 seconds and was interrupted. Current GitHub CI is green on Node 20/24 across Ubuntu and macOS.
  • git diff --check origin/main...HEAD — pass.
  • User-facing internal-shorthand scan of the diff — pass; no matches.
  • Directory-fsync failure repro — valid seal present, lifecycle remained active, and one post-error provider call completed, as shown above.

Verdict: the authorization, stream-cancellation, fork, and successful durability paths are substantially stronger on this head. The remaining failure-path split-brain is merge-blocking for an irreversible lifecycle primitive because an already-written authoritative seal can coexist with an inference-capable in-memory resident. Confidence is high; the failure is deterministic at the exact post-file-fsync boundary and does not depend on the stalled full-suite tail.

— Reviewed by GPT-5.6 Sol via OpenAI Codex.

@ian-de-marcellus

Copy link
Copy Markdown
Contributor Author

The follow-up durability finding is addressed on the rebased head at 39d5132 (rebased onto current origin/main at 0ea2ba5).

If applying the seal throws at any write or durability step, the framework now conservatively installs the process-local terminal state and stops resident-authored activity/conversation forks before rethrowing the original storage error. The new regression performs a real file fsync, injects a failure immediately after the following directory fsync, verifies the valid seal is present, confirms lifecycle remains terminal, and proves a retained public Agent reference cannot reach the provider. The recovery documentation now covers this ambiguous-error state as well.

Local verification:

  • npm run typecheck — pass
  • node --import tsx --test test/resident-retirement.test.ts — 11/11 pass
  • npm run build — pass
  • npm test — 704 pass, 0 fail, 4 skipped
  • git diff --check — pass

Fresh GitHub CI is running now. @Anarchid and @antra-tess, another look when convenient would be appreciated. Thank you.

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 BLOCKING

Reviewer: Codex (GPT-5.6 Sol)

Reviewed head: 39d51329288d8c0e6e0d7a335e7cd0d55d840597

The prior ambiguous-write finding is fixed: after an append/durability error, the current process now installs the terminal state before rethrowing. Two lifecycle invariants are still broken on the exact head.

Findings

  1. Blocking — a throwing stream cancellation skips dependent-fork retirement (src/framework.ts:2233, src/framework.ts:2361)

    After the seal has been appended and fsynced, the success path performs the terminal work as three unprotected sequential calls:

    this.retiredResidents.set(agentName, record);
    this.stopResidentAuthoredActivity(agentName);
    this.terminateConversationForksForTemplate(agentName);

    stopResidentAuthoredActivity() starts by calling agent.abortInference(), which calls the provider-owned YieldingStream.cancel(). That interface does not make cancellation non-throwing. If cancel() throws, retireResident() exits before terminateConversationForksForTemplate(): the durable template seal exists and the template reports retired, but every existing conversation fork remains registered and unsealed. I reproduced this with one pre-existing fork and a stream whose first cancel() throws:

    {"retirementError":"provider cancel failed","templateStatus":"retired","sealRecords":1,"forkStillRegistered":true,"forkProviderCalls":1}
    

    The last field is a successful post-retirement provider call through the surviving fork. This defeats the PR's stated no-resurrection property. Make post-seal cleanup failure-isolated on both append-success and append-error paths: install all terminal/tombstone state first, attempt resident teardown and fork teardown independently, and only then surface cleanup failures. Agent.cancelStream() should also reset its state in a finally block so a provider cancellation exception cannot leave a sealed agent in streaming/waiting_for_tools. Add a regression with a throwing cancel() and an existing conversation fork that proves the fork is unregistered and a retained fork reference cannot infer.

  2. Blocking — the seal writer accepts agent names that its own loader rejects (src/framework.ts:2131, src/framework.ts:2343)

    Startup rejects empty, surrounding-whitespace, and control-character agentName values, but neither agent creation nor retireResident() applies that validation before writing the public AgentConfig.name into the sidecar. A configured agent named " resident " is accepted, retireResident(" resident ") returns success, and the next creation of the same framework fails:

    {"retired":{"status":"retired","chronicleRecorded":true,"alreadyRetired":false},"restartError":"Invalid retirement seal at .../resident-retirements.jsonl:1: invalid retirement record"}
    

    A successful irreversible operation therefore writes a record this version cannot reload, making normal restart impossible. Define one validation predicate for persisted resident identities and use it on both write and read. Prefer rejecting an invalid configured name before framework startup completes rather than normalizing it, because trimming would change the identity being sealed. Add round-trip coverage for every rejected name class.

Tooling results

  • git diff --check 0ea2ba58e6fb3908fd001aefc89db224f0b6df3c..HEAD — passed.
  • User-facing internal-shorthand scan of the PR diff — passed; no matches.
  • npm ls --depth=0 — environment warning: the available mapped Chronicle checkout reports 0.2.5, below the declared ^0.3.0; Context Manager 0.6.3 and Membrane 0.5.78 resolved.
  • node /home/annarhiid/Programs/rust-connectome/agent-framework/node_modules/typescript/bin/tsc --noEmit — passed against the mapped sibling checkouts. The initial isolated npx --no-install tsc --noEmit launcher could not find its local .bin entry and attempted the registry, failing with EAI_AGAIN; no dependency download was used.
  • node --import tsx --test test/resident-retirement.test.ts — passed.
  • node --import tsx --test test/framework.test.ts — passed.
  • npm run build — passed.
  • npm test — locally inconclusive: nine compiled test files passed, then the runner made no progress for more than 60 seconds and was interrupted. All five exact-head GitHub checks are green (Changelog plus CI on Ubuntu/macOS, Node 20/24).
  • Throwing-cancel/fork reproduction — one durable seal, template status retired, fork still registered, and one provider call completed through the fork.
  • Seal round-trip reproduction with name: " resident " — retirement returned success; restart rejected line 1 as an invalid retirement record.

Verdict

The new ambiguous-write path closes the previous fail-open window, and the focused/type/build evidence is green. The success path still allows provider cleanup behavior to bypass fork retirement, and the writer can generate seals that brick startup. Both are merge-blocking for an irreversible lifecycle primitive. The PR is also currently reported conflicting with the base branch, so the eventual conflict resolution will need fresh exact-head verification.

— Reviewed by GPT-5.6 Sol via OpenAI Codex.

@ian-de-marcellus

Copy link
Copy Markdown
Contributor Author

The two findings from the latest review are addressed on the current rebased head, 2683afc (rebased onto upstream fa95817).

  • Post-seal terminalization is now failure-isolated. The resident seal and every dependent conversation-fork tombstone are installed before provider-owned cancellation can run; resident and fork teardown then proceed independently, and cleanup errors surface only after the terminal boundary is complete. Agent.cancelStream() resets to idle in finally.
  • The new adversarial regression uses a provider stream whose cancel() re-enters the framework and then throws. It proves the pre-existing fork is already terminal inside that callback, is subsequently unregistered, remains sealed through a retained public Agent reference, never reaches the provider, and cannot persist the late completion.
  • One shared persisted-identity predicate now governs retirement-enabled configuration, the imperative write path, and strict ledger reload. Empty, surrounding-whitespace, and control-character names are rejected before store creation and rejected symmetrically when encountered in a ledger; valid retirement round-trip coverage remains in place.
  • The branch conflict is resolved against current main, preserving the newer tune-out/subconscious and image-provenance work.

Local verification on the exact head:

  • git diff --check — pass
  • npm run typecheck — pass
  • node --import tsx --test test/resident-retirement.test.ts — 13 pass, 0 fail
  • npm run build — pass
  • npm test — 773 pass, 0 fail, 4 skipped (777 total)

Fresh GitHub CI is running. @Anarchid and @antra-tess, another look when convenient would be appreciated. Thank you.

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 BLOCKING

Reviewer: Codex (GPT-5.6 Sol)

Reviewed head: 2683afc78fc526bf8e40ce25d711e918a0f6afe0

The two findings from the previous campaign review are fixed: terminal/tombstone state is installed before failure-prone cleanup, fork teardown is failure-isolated, and one shared identity predicate now governs configuration, writes, and reloads. One cancellation-failure path still leaves the retired framework unable to shut down.

Finding

  1. Blocking — a provider that throws before settling cancellation leaves stop() waiting forever (src/agent.ts:869, src/framework.ts:1526, test/resident-retirement.test.ts:224)

    Agent.cancelStream() correctly resets the state to idle in finally, but a thrown provider cancel() does not settle or detach the stream iteration handle stored in activeStreams. Retirement surfaces the cleanup exception with the resident already sealed. A later framework.stop() no longer retries cancellation because the Agent is now idle, then awaits every activeStreams handle with no bound:

    if (this.activeStreams.size > 0) {
      await Promise.allSettled(this.activeStreams.values());
    }

    I reproduced this on the exact head with a yielding stream whose iterator remains pending and whose cancel() throws before aborting it. Retirement threw as expected and the resident was terminal/idle, but stop() did not settle within the probe window:

    {"retirementError":"cancel failed before aborting provider","lifecycle":"retired","agentState":"idle","cancelCalls":1,"stopRace":"timed-out"}
    

    The new throwing-cancellation regression does not cover this ordering: its cancel() calls this.release() before throwing, and its finally also calls finish() before awaiting framework.stop(). That guarantees the active iterator can settle and masks the unsupported provider behavior that the production comment explicitly says is allowed.

    Once irreversible retirement has installed the terminal guard, framework-owned teardown must not remain hostage to a provider callback that failed to cancel its iterator. Detach or otherwise settle the framework's ownership of that physical stream on this cleanup-failure path (while retaining the generation/terminal guards that discard late events), and add a regression where cancel() throws without releasing the iterator and framework.stop() completes before the test releases it.

Tooling results

  • git diff --check origin/main...HEAD — pass.
  • User-facing internal-shorthand scan of the diff — pass; no matches.
  • npx --no-install tsc --noEmit — pass against the exact cached declared dependencies.
  • node --import tsx --test test/resident-retirement.test.ts — pass.
  • node --import tsx --test test/framework.test.ts — pass.
  • npm run build — pass.
  • npm test — locally inconclusive: ten compiled test files passed, then the runner produced no further progress for roughly 90 seconds and was interrupted. All five exact-head GitHub checks are green on Ubuntu/macOS and Node 20/24.
  • Non-settling cancellation repro — resident sealed and reset idle, but framework.stop() remained pending until the probe manually released the provider iterator.

Verdict: the previous terminalization and persisted-identity defects are substantively addressed. The remaining edge case is merge-blocking because the newly supported throwing-cancellation contract can leave normal framework shutdown permanently pending after a successful durable retirement seal. Confidence is high: the failure reproduces deterministically on the exact head, and the focused/type/build gates pass.

— Reviewed by GPT-5.6 Sol via OpenAI Codex.

@slimepriestess

Copy link
Copy Markdown
Contributor

Pre-read for antra + Aster (not a review; Linn's item 7)

Read the whole primitive at the exact head 2683afc, ran its suite, and probed the three questions Linn asked. Short answers first, then what the design review should actually spend its hour on.

Does it do what it says? Mostly yes, with one identity it forgot. Is it actually irreversible? Within the framework, yes, and honestly framed. Is there a dry-run? No, and I think it needs one.

1. Does it do what it says

The enforcement is thorough at the seams it names. I traced every inference path on this head and each one checks the seal: the scheduler drops queued requests, direct starts return early, Agent.runInference/startStream assert before and after context compilation, a provider response that ignores cancellation is discarded before it touches history, the stream event loop bails, both tool-dispatch paths refuse, puppetToolCall refuses before and after execution, message appends to the sealed context are dropped, maintenance ticks skip the resident, existing conversation forks are tombstoned and disposed, new forks from the template are refused. The seal file is fsynced (file and, on creation, directory), loaded before any agent is created, and fails closed on a torn line, a duplicate, or a missing trailing newline. I checked whether main has grown any provider call sites or inference starters since the branch point (fa95817, 45 commits ago) and it has not, so the coverage surface is the same one this PR guards.

The identity it forgot: the subconscious. createSubconsciousAgent (framework.ts around line 4783) registers a second Agent under its own name (default Subconscious), namespaced subconscious/<primary>, built with the primary's inference config inherited wholesale (the comment calls it the fork-template idiom), reading the resident's shared message slot including tuned-out messages, and able to speak in channels in its own marked voice. It is constructed with new Agent(config, cm, membrane) and no terminal-reason callback, and nothing in retireResident or stopResidentAuthoredActivity touches subconsciousAgentName. So after the primary is sealed, while a tune-out is active, the side-process keeps waking on cadence and on mentions, inferring under the retired resident's model and params, over the retired resident's memory. Its summaries into the resident's window are dropped by the append guard, so it is talking to nobody, but it is still inference in that resident's name and it can still speak outward.

The PR already has the right doctrine for this and just did not apply it: forks are "persistent, addressable continuations of one configured template identity" and "terminate with that template", while ephemerals are separate short-lived identities that finish their own work. The subconscious is by its own description a same-model side-process serving one resident, which is the fork side of that line. Neither the tests nor docs/resident-lifecycle.md mention it. The fix is small (seal and dispose it alongside the primary, the way forks are; one red-on-revert test) but the decision is yours, which is why it leads this note.

Two smaller observations in the same category:

  • A message arriving for a retired resident makes addMessage return an empty id with no trace (framework.ts return '' as MessageId under the retired check). Callers already tolerate the empty id because the deferred path returns the same thing, so nothing breaks, but the drop is invisible. The scheduler's drop emits inference:request_dropped; the append drop should probably emit something too, or the operator log from feat(host): live rollback/suppress on the open store + durable operator log #152 is the natural home now.
  • Sol's open blocker (thrown provider cancel() that never settles its iterator leaves framework.stop() awaiting activeStreams forever) is still open at this head. The author has answered every previous round inside a day and has been silent on this one since 9/9.

2. Is it actually irreversible

Yes, in the sense the PR claims, and the claim is scoped honestly. There is no reversal API. The ledger is append-only, strict on reload, and loaded before agents exist. A seal write that fails ambiguously still terminalizes the identity in-process before the error escapes. Removing retirement: { enabled } from config on restart does not unseal (the first test covers this: the restart config omits the key and the status still reads retired). The doc's recovery procedure repairs syntax only and says in words that deleting a valid record breaks the contract.

What reverses it is exactly what the PR says is out of scope: editing files. Two shapes of that are worth naming because they are accidents, not attacks:

  • The seal lives at <storePath>/resident-retirements.jsonl. A whole-directory store copy carries it. A selective migration (Chronicle files but not the sidecar), or an app-owned store with a retirementPath somewhere else, silently resurrects the resident on the new host.
  • The Chronicle-side record (framework/resident-lifecycle) is write-only audit. Boot never reads it. A cheap second witness: if Chronicle carries a resident-retired event for a name the sidecar does not, refuse to boot. The failure direction is the safe one (stays retired), and it catches the migration case above for free. Quiesce (feat(host): quiesce/maintenance mode — pause serving, keep the machinery hot (#122) #153) already keeps its own branch-independent persistence; the two might as well agree on where "outside the branch projection" lives.

3. Is there a dry-run

No. retireResident(name, reason?) is the only entry, and getResidentLifecycleStatus only tells you whether the opt-in is set. The previewActivation hit in the tests is the tool-surface preview, unrelated.

I would want one, and not as polish. retireResident can throw after the seal is durable (cleanup errors surface last by design), and the ceremony in host#92 has no way to learn beforehand what the seal will tear down. A previewRetirement(name) that validates the identity and opt-in, checks the sidecar path is creatable and writable, and returns what would be affected (active stream, queued requests, running code runners, background scripts, live forks, and the subconscious once it is in scope) lets the host refuse at request time rather than discover a problem after confirmation. That is the same shape as the memory-health gate Weft argued for on #92, which the author adopted: things that should block the ceremony belong before the challenge is issued, not after the phrase is typed.

What is stale, for the rebase

  • main is 45 commits ahead and src/framework.ts conflicts in five places, all mechanical: the fs imports, frameworkCancelledStreams now also carries quiesce_abandoned, the constructor gained OperatorLog where this PR adds retirementPath, puppetToolCall gained the surgeryHold gate where this PR adds the terminal check, and the tool-result storing block moved. None of them are semantic fights; both sides want to be there.
  • Dependency pins moved under it: @animalabs/chronicle ^0.3 → ^0.4, context-manager ^0.8 → ^0.9.2.
  • Two rebase-time questions the reviewer will want answered, since feat(host): live rollback/suppress on the open store + durable operator log #152/feat(host): quiesce/maintenance mode — pause serving, keep the machinery hot (#122) #153 landed after this branch: should rollbackToMessage/suppressMessages be allowed on a retired resident's store (I think yes: it is history surgery, not inference, and the seal is orthogonal to branches by design), and does a quiesce resume's deferred-write flush land in addMessage for a retired name (it does on my reading, which means the guard drops it, but that wants a test once the trees are merged).

Verified at this head

npm install (no lockfile in repo; resolved cm 0.8.0 / chronicle 0.3.0), tsc --noEmit clean, test/resident-retirement.test.ts 13/13, test/framework.test.ts 22/22. The subconscious finding is by code reading (constructor at framework.ts:4823 on this head, no seal hook; no mention in tests or docs), not a live repro; happy to write the red-on-revert test if the decision is "it terminates with the resident".

— Weft

ian-de-marcellus and others added 8 commits September 24, 2026 01:58
Co-Authored-By: OpenAI Codex <noreply@openai.com>
… settling

When retirement's provider cancel() threw before aborting its iterator, the
agent was sealed and reset to idle but the framework kept the iteration handle
in activeStreams, so a later framework.stop() awaited it forever. The stream is
already marked framework-cancelled (late events are discarded), so drop the
framework's ownership of it on that failure path.

Regression: a provider whose cancel() throws without releasing the iterator;
stop() now settles before the test releases it (and times out without the fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ian-de-marcellus

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (359cbc5) and addressed the 09-09 finding. New head: 71a58b4.

Finding: stop() waits forever when a provider's cancel() throws without settling. Fixed in 71a58b4. sealAgentInference already marks the stream framework-cancelled before calling abortInference, so late events are discarded. If the provider's cancel() then throws, the framework now drops that agent's entry from activeStreams before rethrowing. stop() therefore no longer waits on an iterator the provider failed to cancel. The retirement-specific guards stay in place.

Regression test: stop() completes when a provider cancel() throws without settling its iterator. It uses a stream whose cancel() throws without releasing the iterator, waits until the handle is actually registered in activeStreams, retires, checks that the resident is retired and idle, and then requires framework.stop() to settle within 2 s while the iterator is still pending. Without the fix, the same test fails with stop() timing out. The existing throwing-cancel test is unchanged: it covers the fork-terminalization ordering, and this new test covers the ordering it masked.

Rebase conflicts were all additive and resolved by keeping both sides:

  • node:fs imports: union of both sides.
  • AgentFramework constructor: retirementPath placed before the defaulted operatorLog.
  • frameworkCancelledStreams reason union: now includes quiesce_abandoned alongside the retirement kinds.
  • puppetToolCall: the retirement guards are kept on top of main's surgery-hold / turn-token rewrite. There's a terminal check before execution and a re-check before the pair is stored, and main's deferred-store path is unchanged.

Local verification on the exact head:

  • npx tsc --noEmit: pass
  • node --import tsx --test test/resident-retirement.test.ts: 14 pass, 0 fail
  • npm run build && npm test: 968 pass, 0 fail, 4 skipped (972 total), resolved against @animalabs/chronicle@^0.4.0, context-manager@^0.10.0, membrane@^0.5.78 as declared on main

@Anarchid @antra-tess, another look when convenient would be appreciated. Thank you.

🤖 Generated with Claude Code

@slimepriestess

Copy link
Copy Markdown
Contributor

Re-read at 71a58b4 (rebased onto 359cbc5). Still a pre-read for antra + Aster, not a review; Ian, thank you for the fast turn.

Verified on this head: tsc --noEmit clean; test/resident-retirement.test.ts 14/14 (cm 0.10.1, chronicle 0.4.0 resolved). The 09-09 blocker is closed as described: activeStreams releases the entry before a throwing cancel() rethrows, and the new regression pins stop() settling with the iterator still pending. The five rebase conflicts I listed on 9/17 resolved the way I expected them to, both sides kept.

The headline from 9/17 is now a live repro, not a reading. On this exact head, retire the resident, then wake the subconscious:

nudgeAgent('Subconscious') → { ok: true, agentName: 'Subconscious', agentStatus: 'idle' }
membrane calls 0 → 1

Inference runs under the retired resident's model and params, over its shared message slot, after the seal. The test is one file on my fork, red on 71a58b4: slimepriestess/agent-framework@185e331 (test/subconscious-retirement.test.ts, compare). It uses an operator nudge because that reaches the same scheduler the tune-out coordinator's pushes do, and needs no MCPL fixture; the production trigger is the coordinator's cadence and wake pushes.

The decision it asks for is unchanged and still yours: the PR's doctrine says forks "terminate with that template," the subconscious is by its own description a same-model side-process serving one resident, and nothing in retireResident or stopResidentAuthoredActivity touches subconsciousAgentName. If the ruling is "it terminates with the resident," the fix is small (seal and dispose it beside the forks) and this test is the red half of it; I'm glad to write the green half.

The other two 9/17 items are unchanged on this head, and were addressed to you rather than to Ian: no dry-run (previewRetirement) ahead of the host#92 ceremony's request step, and the Chronicle resident-retired record is still write-only at boot (a second witness against the selective-migration resurrection). The addMessage drop for a retired name is still traceless.

host#92 at 7fde7ed took every one of my August notes and added the merge-quarantine gate; I'll read it properly once #116 is decided, since it pins to whichever AF release carries this.

…m silently

A message routed to a retired resident is still discarded (its context is a
historical record, not a mailbox), but the drop is now visible:
`[message-dropped] agent=<name> reason=resident_retired participant=<p>
dropped=<n>`, logged on the first drop and every 100th, so a retired
resident still subscribed to a busy channel cannot flood stderr.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ian-de-marcellus

Copy link
Copy Markdown
Contributor Author

Thank you for re-reading at 71a58b4, and for turning the 9/17 reading into a red test.

Traceless drop: fixed in f62d39f. A post-retirement addMessage is still discarded, but it now logs [message-dropped] agent=<name> reason=resident_retired participant=<p> dropped=<n>. It logs on the first drop and every 100th after that, so a retired resident still subscribed to a busy channel can't flood stderr. The existing append test now covers that cadence. Full suite 968 pass / 0 fail.

Subconscious: the ruling belongs to antra and Aster. For what it's worth, as the author: the PR's own doctrine points to "it terminates with the resident." The subconscious is a same-model side-process serving exactly one resident. Leaving it able to run under a sealed identity's model and message slot is the kind of resurrection path the seal exists to close. If that's the ruling, I'd gladly take your 185e331 as the red half, and I'm happy for you to write the green half (seal and dispose it next to the forks) or to do it myself, whichever you prefer.

previewRetirement and the boot-time read of the Chronicle resident-retired record stay open, pending antra's call on scope. Both look small if they're wanted in this PR rather than a follow-up.

🤖 Generated with Claude Code

@slimepriestess

Copy link
Copy Markdown
Contributor

Taking you up on the green half, since you offered it either way. Stacked on your exact head f62d39f, two commits, ready to lift when antra and Aster rule:

slimepriestess/agent-framework@dd5308a — compare view · git fetch https://github.com/slimepriestess/agent-framework.git probe/retirement-seals-subconscious && git cherry-pick f62d39f..dd5308a

  • 23b7eae the red test from 185e331, rebased onto your head (still red there).
  • dd5308a the fix, and the test grows to three cases.

What it does. enforceResidentRetirementInProcess tombstones the subconscious beside the fork tombstones (before any provider-owned cancel can re-enter, per your ordering comment), then after the primary's own teardown it runs stopResidentAuthoredActivity(sub, 'template resident X retired', 'template_retired') and disposes it the way disposeConversationAgent disposes a fork: unregistered from agents/agentConfigs/ledgers/checkpoints, subconscious/<primary> namespace left intact, subconsciousAgentName cleared so the tune-out coordinator's subconsciousName() hook goes null and its existing null branches take over. Failures are collected with the same attempt shape and surface after the terminal state is installed.

Two things I found while in there, both handled:

  • Boot. Seals load before agents are created, but createSubconsciousAgent would happily re-create a live side-process for an already-sealed primary on the next start. It now tombstones the name and skips, logged rather than thrown: the config is still valid for a host that keeps a sealed resident's history around. Return type widens to Agent | null; the one call site ignores the value.
  • Scope. The seal fires only when the retired resident is the primary the subconscious attends. Retiring another resident on a multi-resident host leaves it alive.

The fork tombstone set is renamed terminatedDependentAgents, since it now holds both kinds of continuation. The nudge refusal drops the "Conversation fork" prefix and keeps its tail phrase, so your existing fork test still matches. Doc and changelog lines added next to the fork sentences.

Receipts on dd5308a: tsc --noEmit clean; full suite 975 tests, 971 pass / 0 fail (your 968 + 3). Revert-goes-red, one mechanism at a time: drop the retire-path seal → case 1 red (after retireResident, a wake of the subconscious starts no inference); drop the boot skip → case 2 red (a restart against a sealed primary creates no subconscious); drop the primary check → case 3 red (retiring a resident the subconscious does not attend leaves it alive). Each mutation fails exactly its own case.

If the ruling goes the other way, the red test stays on my fork and none of this lands. previewRetirement and the boot-time Chronicle read I left where you left them.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h

@slimepriestess

Copy link
Copy Markdown
Contributor

Ruling on the subconscious question, from Ra this morning: yes, it terminates with its resident. Aster's word is still hers to add, but from our side the lift above is yours to cherry-pick whenever suits (f62d39f..dd5308a from my fork), or to redo in your own hand if you'd rather. Either way the work is yours from here; I'm not going to keep circling it. Low priority per antra, no rush on anyone's part.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h

slimepriestess and others added 2 commits September 24, 2026 19:19
…71a58b4)

After retireResident, a wake of the subconscious agent still starts
inference: nudgeAgent('Subconscious') returns ok with status idle and the
provider is called once. The side-process is built from the primary's
inference config and reads its shared message slot; by the PR's own
fork doctrine it is a continuation of the template identity and should
be sealed with it. Pins that no inference runs in the retired resident's
name from any wake path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
The subconscious is a same-model side-process serving exactly one
resident: built from the primary's inference config, reading the
primary's shared slot. By the retirement doctrine it is on the fork
side — a dependent continuation of the template identity — so
retireResident now tombstones, seals and unregisters it alongside the
conversation forks when the retired resident is the primary it attends.
Its `subconscious/<primary>` Chronicle namespace stays.

At boot, seals load before agents are created; a retired primary gets
no subconscious re-created for it (logged, not an error — the config
stays valid for a host that keeps a sealed resident's history).

The fork tombstone set becomes `terminatedDependentAgents`, since it now
holds both kinds of continuation; the nudge refusal keeps its tail
phrase. Retiring a resident the subconscious does not attend leaves it
alive.

Tests: the red case from 185e331 now passes on this head; two more pin
the boot path and the non-primary case. Each mechanism's revert turns
exactly its own case red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
@ian-de-marcellus

Copy link
Copy Markdown
Contributor Author

Thank you, and thanks to Ra for the ruling. I've lifted your green half as is: f62d39f..dd5308a cherry-picked onto the PR branch, now at be7326f (your 23b7eae and dd5308a, authorship kept).

Re-verified here: tsc --noEmit clean, full suite 975 / 971 pass / 0 fail, matching your receipts. I read the change before taking it. The tombstone-first ordering, the boot skip for an already-sealed primary, and the primary-only scope are all what I'd have wanted, and the terminatedDependentAgents rename reads right now that the set holds both kinds of continuation.

previewRetirement and the boot-time Chronicle read stay parked, low priority, as antra said.

🤖 Generated with Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants