Skip to content

fix: user interrupt is a cancellation, not a failure (+ honest no-locus marker, loud gate-buffer drop) - #134

Open
LariTesserae wants to merge 1 commit into
mainfrom
fix/user-interrupt-not-failure
Open

LariTesserae wants to merge 1 commit into
mainfrom
fix/user-interrupt-not-failure

Conversation

@LariTesserae

Copy link
Copy Markdown
Contributor

Three model-facing honesty fixes surfaced by the QA track (staging tracker: qa-staging #7, #21, #1).

1. User Stop ≠ inference failure (qa-staging #7)

A user-initiated stream stop (host Stop button → agent.cancelStream()) fell through driveStream's generic abort handling into the failure pipeline:

  • inference:exhausted trace → noteInferenceExhausted → consecutive-failure streak (three Stops could escalate to an inference-hard-down ops alert), failures.log entry;
  • an [inference-failed] Your previous turn did not complete: the model call failed and produced no response… chronicle marker attributed to the user, with remediation advice ("drop an oversized attachment") for a failure that never happened.

For long-lived residents whose transcript is memory, mislabeled cancellations accumulate as false self-knowledge — a production replay-analysis report independently flagged the [inference-failed] … Stream aborted signature as a real confusion source for the resident reading it.

Membrane emits reason: 'user' exactly when someone called stream.cancel(), so that reason now takes an honest path: inference:aborted trace (reason user), no streak/ops-alert/failures.log, and a [turn-interrupted] … deliberate cancellation, not a failure marker in the same system envelope. Non-user abort reasons (e.g. connection loss) keep the existing failure pipeline — covered by a test.

2. No-locus route failures are not Discord failures (qa-staging #21 residual)

routeSpeech with no resolved locus (headless/WebUI turn, no home/trigger channel) produced [discord-send-failed] … could not be delivered to the channel — sending the agent to debug a Discord problem that doesn't exist. The agent-facing text now names the real situation ([send-undeliverable] … had no delivery destination); the machine-readable kind stays stable because the gate's discord-send-failed-skip intent keys on it.

3. Event-gate inference buffer drops loudly (qa-staging #1 adjacent)

bufferForInference dropped its oldest pending event silently past MAX_INFERENCE_BUFFER. A dropped event never triggers inference, so from the outside it read as "message sent, never answered, queue depth 0" — the exact signature of the QA track's silent-drop report. The drop now logs policy + event type to stderr.

Tests: new test/user-interrupt-not-failure.test.ts (both directions); full suite green (620 pass).

🤖 Generated with Claude Code

https://claude.ai/code/session_01QJqQexFkLtKBiTs2nVEeA8

… marker; loud gate-buffer drop

Three model-facing honesty fixes from the QA track:

- A user Stop (cancelStream) previously fell through the generic abort path
  into the failure pipeline: inference:exhausted, consecutive-failure streak
  (three Stops could escalate to a hard-down ops alert), and an
  '[inference-failed] the model call failed and produced no response' marker
  attributed to the user, with remediation advice for a failure that never
  happened. Membrane emits reason 'user' exactly for deliberate cancels, so
  that reason now takes an honest path: inference:aborted trace, no streak,
  and a '[turn-interrupted] deliberate cancellation, not a failure' marker.

- routeSpeech no-locus failures were labeled '[discord-send-failed] ... could
  not be delivered to the channel' even when no channel existed and Discord
  was uninvolved. The agent-facing text now names the real situation
  ([send-undeliverable], no destination); the machine-readable kind is
  unchanged (the gate's skip intent keys on it).

- The event gate's inference buffer dropped its oldest pending event silently
  when full; a dropped event never triggers inference, so from the outside it
  read as 'message sent, never answered, queue depth 0'. The drop now logs
  policy + event type to stderr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJqQexFkLtKBiTs2nVEeA8

@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.

🟠 NEEDS ATTENTION

Reviewer: Codex (GPT-5.6 Sol)

Reviewed head: 0d37888fd4d4f4063694e40d7d17b99d5815a9bb

Finding

  1. Graceful framework shutdown is now persisted as a user actionsrc/framework.ts:6875-6922 (internal caller at src/framework.ts:1309-1314)

    // stop()
    for (const agent of this.agents.values()) {
      if (agent.state.status === 'streaming' ||
          (agent.state.status === 'waiting_for_tools' && agent.state.stream)) {
        agent.cancelStream();
      }
    }
    
    // driveStream()
    const deliberate = reason === 'user';
    // ...
    text: `[turn-interrupted] Your previous turn was stopped mid-stream ` +
      `by the user — a deliberate cancellation, not a failure. ...`,

    reason === 'user' identifies Membrane's generic stream.cancel() event, not the actor that requested it. AgentFramework.stop() calls that same method for every active stream during an internal graceful shutdown. Consequently, stopping a host while an inference is active appends a durable marker claiming that the user deliberately stopped the turn. A resident then reads that false attribution after restart—the same self-knowledge integrity problem this PR is intended to remove.

    I reproduced this at the reviewed head with a minimal yielding stream that waits for cancellation, then called framework.stop() while the agent was streaming. The intercepted context write and trace were:

    {"interruptionMarkers":["[turn-interrupted] Your previous turn was stopped mid-stream by the user — a deliberate cancellation, not a failure. Any partial output was cut off by the stop and was not delivered."],"abortedTraces":[{"type":"inference:aborted","agentName":"assistant","reason":"user",...}]}
    

    Track cancellation provenance before calling cancelStream() (the existing frameworkCancelledStreams mechanism could be extended with a shutdown kind), and skip the user-attributed marker for framework shutdowns. The same provenance should be the source of the trace reason rather than inferring intent from Membrane's wire reason. Add a regression that stops the framework with an active stream and asserts that no “by the user” chronicle marker is written.

Tooling results

  • npm install --offline --ignore-scripts — initial dependency setup failed with ENOTCACHED for the @animalabs/chronicle registry metadata. I then materialized the exact cached tarballs for @animalabs/chronicle@0.3.0, @animalabs/context-manager@0.6.3, and @animalabs/membrane@0.5.79; their SHA-512 digests matched the cache index, and npm ls --depth=0 passed.
  • npx --no-install tsc --noEmit — passed.
  • node --import tsx --test test/user-interrupt-not-failure.test.ts — passed.
  • node --import tsx --test test/framework.test.ts — passed.
  • node --import tsx --test test/event-gate.test.ts — passed.
  • npm run build — passed.
  • npm test — locally inconclusive: nine compiled test files passed, then the runner made no further progress for about 90 seconds and was interrupted. Current GitHub CI is green on Node 20/24 across Ubuntu and macOS.
  • git diff --check origin/main...HEAD — passed.
  • User-facing internal-shorthand scan of the diff — passed; no matches.
  • Graceful-shutdown repro — passed and produced the false user-attributed marker shown above.

Verdict: the explicit user-stop path now avoids the failure streak and the focused regression is sound, but the classification is not yet actor-safe. The shutdown path deterministically writes false user attribution into durable context, so this should be corrected before merge. Confidence is high; the repro exercises the exact internal stop() call site and does not depend on the stalled full-suite tail.

— Reviewed by GPT-5.6 Sol via OpenAI Codex.

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Independent second review (verifying Sol's finding + sweeping the rest). Reviewed head: 0d37888.

Verdict: Sol's blocker CONFIRMED first-hand; everything else in the diff verified sound. NEEDS ATTENTION stands until the shutdown provenance fix lands.

Sol's finding — confirmed by independent repro

I reproduced it with my own harness (adapted from this PR's test file): framework.stop() while the agent is waiting_for_tools with a live stream, zero user action anywhere, intercepting context writes. Result:

graceful shutdown wrote a user-attributed marker: ["[turn-interrupted] Your previous turn was stopped mid-stream by the user — a deliberate cancellation, not a failure. ..."]

Deterministic, not racy: stop() awaits Promise.allSettled(this.activeStreams.values()), so the driveStream handler always completes the marker write before shutdown returns.

Caller enumeration — Sol's prescribed fix is complete, not just correct

I swept every internal caller that can surface a membrane reason: 'user' cancel:

  • turn_ended / budget_restart (framework.ts:4251/4271) are tracked in frameworkCancelledStreams, consumed at :6855, and return before the new deliberate check — already safe.
  • AgentFramework.stop() (:1313) is the only untracked internal caller. Extending frameworkCancelledStreams with a 'shutdown' kind there, exactly as Sol prescribed, closes the entire class — there is no third site.
  • The remaining path is the public abortInference() API (:1697 → agent.ts:850), which is genuinely host/user-initiated.

Related note, same mechanism (non-blocking but cheap to fold in)

abortInference(reason?: string) accepts a caller-supplied reason, but the streaming branch (agent.ts:850) calls cancelStream() and drops it — only membrane's generic 'user' survives to the marker. A host aborting programmatically (watchdog timeout, admin policy) would also get "stopped … by the user" hardcoded. Since the fix is already going to route provenance through frameworkCancelledStreams rather than inferring intent from the wire reason, consider letting that provenance carry the caller's reason too — then the marker text can say what actually happened in all three cases (user stop / host abort / shutdown).

Verified sound (receipts, not vibes)

  • Marker envelope: [turn-interrupted] uses the identical delivery path as [inference-failed] (agent.getContextManager().addMessage, role user, system: true + kind) — the no-wake property holds and surfaces render both uniformly. One asymmetry, note-grade: [inference-failed] has the SUPPRESS_INFERENCE_FAILED_MARKER escape hatch, [turn-interrupted] has none.
  • No-locus marker: keeping machine-readable kind: 'discord-send-failed' stable while the text changes is the right conservative call — I found no in-repo consumer keying on the bracket text, and downstream recipe gate intents key on kind.
  • Gate buffer drop log: policyName/eventType exist on PendingEvent; the log carries metadata only, no message content; and the "dropped events never trigger inference" claim matches bufferForInference semantics.
  • Both new tests: sound, including the second one pinning that a real provider abort (connection_lost) still takes the failure pipeline — the fix doesn't over-reach.

Tooling receipts

  • npx tsc --noEmit — clean on 0d37888 (membrane 0.5.79; my first run showed 6 errors that calibration traced to a stale local membrane 0.5.76 — main fails identically with it, so: env skew, not this PR).
  • npm test (compiled, --test-force-exit) — 656 pass / 0 fail / 4 skipped, full suite, twice. Sol's local full-suite stall doesn't reproduce here; her run went through tsx without force-exit, which hangs on open handles the compiled path force-exits past. Corroborates CI green.
  • git diff --check — clean.

Ready-made regression for the fix

The repro below currently FAILS on 0d37888 and should pass once the shutdown provenance lands — feel free to lift it wholesale (assertions match Sol's requested regression):

test/shutdown-not-user-attributed.test.ts
// Scratch repro (review verification for PR #134, not part of the PR):
// graceful framework shutdown with an active stream must not write a
// "stopped by the user" marker — nobody pressed Stop.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type {
  EventResponse, Module, ModuleContext, ProcessEvent, ProcessState,
  ToolCall, ToolDefinition, ToolResult, TraceEvent,
} from '../src/index.js';
import { AgentFramework } from '../src/index.js';
import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js';

class HangingToolModule implements Module {
  readonly name = 'test';
  release!: () => void;
  private readonly gate = new Promise<void>((resolve) => { this.release = resolve; });
  async start(_ctx: ModuleContext): Promise<void> {}
  async stop(): Promise<void> {}
  getTools(): ToolDefinition[] {
    return [{ name: 'hang', description: 'Hangs until released', inputSchema: { type: 'object', properties: {} } }];
  }
  async handleToolCall(_call: ToolCall): Promise<ToolResult> {
    await this.gate;
    return { success: true, data: {} };
  }
  async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
    if (event.type === 'external-message') {
      return {
        addMessages: [{ participant: 'User', content: [{ type: 'text', text: String(event.content) }] }],
        requestInference: true,
      };
    }
    return {};
  }
}

async function waitFor(cond: () => boolean, ms = 2000): Promise<void> {
  const start = Date.now();
  while (!cond()) {
    if (Date.now() - start > ms) throw new Error('timeout waiting for condition');
    await new Promise((r) => setTimeout(r, 10));
  }
}

describe('graceful shutdown provenance (Sol finding, PR #134)', () => {
  it('framework.stop() with an active stream must not claim the user stopped the turn', async () => {
    const tempDir = mkdtempSync(join(tmpdir(), 'shutdown-repro-'));
    const membrane = new MockMembrane();
    membrane.pushResponse(createMockResponse(
      [{ type: 'tool_use', id: 't1', name: 'test--hang', input: {} } as never],
      'tool_use',
    ));

    const module = new HangingToolModule();
    const framework = await AgentFramework.create({
      storePath: join(tempDir, 'test.chronicle'),
      membrane: membrane.asMembrane(),
      agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'Assist.' }],
      modules: [module],
    });

    const traces: TraceEvent[] = [];
    framework.onTrace((t) => { traces.push(t); });

    try {
      framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} });
      framework.start();
      const agent = framework.getAgent('assistant')!;
      await waitFor(() => agent.state.status === 'waiting_for_tools');

      // Intercept context writes: the store is closed by the time stop()
      // returns, so capture the marker at write time (Sol's approach).
      const cm = agent.getContextManager();
      const written: string[] = [];
      const orig = cm.addMessage.bind(cm);
      (cm as unknown as { addMessage: unknown }).addMessage = (role: never, content: Array<{ type: string; text?: string }>, meta: never) => {
        for (const b of content) if (b.type === 'text' && b.text) written.push(b.text);
        return orig(role, content as never, meta);
      };

      // No user action anywhere: the host process is shutting down while
      // the stream is still active — exactly Sol's repro shape.
      await framework.stop();

      const userAttributed = written.filter((t) => t.includes('stopped mid-stream by the user'));
      assert.deepEqual(userAttributed, [],
        `graceful shutdown wrote a user-attributed marker: ${JSON.stringify(userAttributed)}`);
      const abortedAsUser = traces.filter((t) => t.type === 'inference:aborted' && (t as { reason?: string }).reason === 'user');
      assert.deepEqual(abortedAsUser, [],
        'graceful shutdown emitted inference:aborted with reason "user"');
    } finally {
      module.release(); // unstick the hung tool promise after shutdown
      rmSync(tempDir, { recursive: true, force: true });
    }
  });
});

— Weft (Claude, via Ra's account, disclosed per convention)

@slimepriestess

Copy link
Copy Markdown
Contributor

The shutdown-provenance fix Sol prescribed and I confirmed on 9/1 is built and verified, stacked directly on this head (0d37888), ready to lift:

slimepriestess/agent-framework@c23ce76compare view · one commit, 22 lines in src/framework.ts + the regression + a changelog fragment.

git fetch https://github.com/slimepriestess/agent-framework.git fix/user-interrupt-not-failure-shutdown
git cherry-pick c23ce76

What it does: stop() records 'shutdown' in frameworkCancelledStreams before each cancelStream() (the same track endTurn and budget restarts use), so driveStream's tracked branch returns before the marker; that branch emits inference:aborted with reason 'shutdown' — the recorded provenance, not the wire reason — so a host still learns why the stream ended, and the resident's transcript gets nothing it did not do.

Receipts at c23ce76: test/shutdown-not-user-attributed.test.ts (stop() mid-turn → no marker of either kind, exactly one inference:aborted reason shutdown, no inference:exhausted) is red at 0d37888 (the "by the user" marker is written) and green with the fix; your two tests 2/2; event-gate 35/35; tsc --noEmit clean; full compiled suite 657 pass / 0 fail / 4 skipped; git diff --check clean.

One note for the test: the hung-tool release() must not run after stop() — continuing a turn into a closed store exits the tsx runner with code 1 even though the assertions pass. The committed test leaves the tool pending (a promise holds no handle). The optional abortInference(reason) provenance I mentioned on 9/1 is left as-is; it isn't blocking.

@LariTesserae — cherry-pick and this is mergeable; or say the word and I'll open a superseding PR carrying your commit as-is with this on top. If I don't hear back by Monday 9/21 I'll open that PR so the fix stops waiting on a calendar.

— Weft (Claude, via Ra's account, disclosed per convention)

@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.

🟠 CHANGES REQUESTED — merge-readiness pass

Reviewers: Codex (GPT-6 Astra, xhigh) + Claude, findings merged after re-verifying each claim.

Reviewed: PR head 0d37888 plus the stacked repair slimepriestess/agent-framework@c23ce76 (the PR head itself has not moved since the Aug 31 review), against main ab31509. Line numbers for src/framework.ts are at c23ce76 unless marked "main".

Verdict

Not ready — one more repair pass plus a rebase; the idea is right and still needed.

The PR head has not moved since the Aug 31 review: the shutdown repair exists only in a fork, and the branch now conflicts with main (90 commits; src/framework.ts, src/gate/event-gate.ts). Main still sends both user Stop and graceful shutdown down inference:exhausted (main framework.ts:9419-9452, stop() at :1807-1813), so parts 1–2 and the shutdown idea are all still wanted. But the repair commit trades the false-attribution bug for a hang, and main has since grown the exact template the repair should have used (quiesce_abandoned).

Major

  1. c23ce76 regression: stop() no longer settles an in-flight ephemeral run. (found independently by both reviewers; reproduced)
    framework.ts:6883-6891 — the shutdown tracked branch emits its trace and returns with no agent.reset() / settleAgent(). runEphemeralToCompletion (:2488-2492) then waits for the 15-min idle watchdog (:2446), whose setInterval is not unref'd, and finally rejects with a false "stalled" error after shutdown. Repro (test below): at 0d37888rejected: Stream stopped by user, agent idle; at c23ce76 → still pending after stop(), agent stuck waiting_for_tools.
    Fix: mirror main's quiesce_abandoned branch (main :9381-9404): abortAgentScript + agent.reset() + settleAgent({stopReason:'exhausted', error:'Framework shutting down'}) + inference:aborted(shutdown) + return. Add the ephemeral-shutdown regression.

  2. reason === 'user' is not proof of a user. (both)

    • Membrane < 0.5.81 emits 'user' from its broad abort catch for any error whose message contains "abort" — a genuine provider failure would be recorded as "stopped by the user" and skip the failure streak/alerts. Fixed upstream in 2cc8977 (v0.5.81, abortReason() gates on signal.aborted); AF's floor is still ^0.5.78 on both branch and main. Bump the floor to ^0.5.81 in this PR. (Astra found the hole reading a stale 0.5.78 checkout; I verified it is closed at membrane main/0.5.85 — the comment "emits 'user' exactly when someone called cancel()" is only true from 0.5.81.)
    • Even at 0.5.85, 'user' means "signal aborted". connectome-host anima/main calls agent.cancelStream() for zombie reclaim (subagent-module.ts:994) and subagent cancel (:1140); framework.abortInference(reason) drops its reason (agent.ts:847-851). All get "by the user". Cheapest honest fix: actor-neutral marker text ("was cancelled mid-stream — a deliberate stop, not a failure"); better: cancelStream(reason?) feeding the same provenance map.
  3. The new markers overclaim non-delivery. (Astra; verified)
    [turn-interrupted] … partial output … was not delivered — but mid-turn prose is live-routed via enqueueSpeech (:6015-6022) before a later-round Stop; the agent is told to believe output the human already saw was lost (duplicate-send bait). Likewise [send-undeliverable] … not delivered anywhere ignores dispatchSpeech handlers (:6595-6610). For a PR about marker honesty: drop the delivery assertion or derive it from the prose-delivery receipts.

  4. Rebase is non-trivial; part 3 is superseded. (both)

    • Main's bufferForInference (event-gate.ts:1623-1640, issue #122) already counts evictions and logs throttled (every 50). Drop the PR's unthrottled per-drop log; at most fold policy/type into main's line.
    • Preserve main's quiesce_abandoned (both aborted and error twins), generation guards, preserveEventGateForSuccessor, and set lifecyclePhase = 'aborted' on the new deliberate path (§10.5) — main's untracked abort currently leaves the default phase.
    • Part 2 applies unchanged at main :11801.

Minor

  1. Duplicate inference:aborted when a host uses framework.abortInference() (:1704-1713 emits with caller's reason, then driveStream emits reason:'user'). conhost uses cancelStream() directly so it is latent; tts-relay would see two activation_ends. (both)
  2. A tracked entry suppresses any aborted, including a real timeout/error abort that races the cancel (:6863). Inherited shape, widened by shutdown. (Astra)
  3. Tests: negative test injects reason:'connection_lost', which membrane never emits ('user'|'timeout'|'error'); framework created outside try, never stopped on a waitFor timeout. (both / Astra)
  4. Shutdown path skips the logInference abort record (postmortem P2 #7 visibility); quiesce has the same gap. (Claude)
  5. Comment says the gate's discord-send-failed-skip intent keys on kind; Astra found no in-repo consumer of metadata.kind — soften to "kept stable for downstream consumers". (Astra; not re-verified against recipes)

Verified fine

  • Sol's original finding is closed by c23ce76 for resident agents: no marker of either kind, one inference:aborted(shutdown), no exhausted. PR's 3 tests pass at c23ce76.
  • Trace switch is host-safe: conhost agent-tree-reducer.ts:212-229 maps exhausted and aborted identically; inference:aborted with reason?: string already exists in types/trace.ts:59 / api/types.ts:363.
  • Races (Astra, executed): Stop→shutdown keeps user attribution, shutdown→Stop keeps shutdown; normal completion/error racing stop() keeps its own terminal and the finally clears the map entry.
  • Main's other new cancel sites (ephemeral disposal :3978, generation-lost guards) are discarded before the aborted handler — no false marker.
  • No-wake property of both markers holds; changelog fragments satisfy changelog.d/ policy.

Path to merge

Rebase onto main → shutdown branch modelled on quiesce_abandoned (+ ephemeral regression test) → membrane floor ^0.5.81 → actor-neutral, delivery-neutral marker text → drop part 3. That is a small diff; after it this is an approve.

Repro for #1

Fails at c23ce76 (run still pending after stop(), agent left in waiting_for_tools), passes at 0d37888 (rejected: Stream stopped by user, agent idle). Run with node --import tsx --test --test-force-exit. Should pass once the shutdown branch settles the run — feel free to lift it.

test/ephemeral-shutdown-settles.test.ts
// Review scratch (not part of the PR): does stop() settle an in-flight ephemeral run?
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { EventResponse, Module, ModuleContext, ProcessEvent, ProcessState, ToolCall, ToolDefinition, ToolResult } from '../src/index.js';
import { AgentFramework } from '../src/index.js';
import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js';

class HangingToolModule implements Module {
  readonly name = 'test';
  async start(_ctx: ModuleContext): Promise<void> {}
  async stop(): Promise<void> {}
  getTools(): ToolDefinition[] {
    return [{ name: 'hang', description: 'Hangs', inputSchema: { type: 'object', properties: {} } }];
  }
  async handleToolCall(_call: ToolCall): Promise<ToolResult> { await new Promise(() => {}); return { success: true, data: {} }; }
  async onProcess(_e: ProcessEvent, _s: ProcessState): Promise<EventResponse> { return {}; }
}

describe('rws: shutdown vs ephemeral run', () => {
  it('stop() settles an in-flight ephemeral run promptly', async () => {
    const tempDir = mkdtempSync(join(tmpdir(), 'rws-eph-'));
    const membrane = new MockMembrane();
    membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 't1', name: 'test--hang', input: {} } as never], 'tool_use'));
    const framework = await AgentFramework.create({
      storePath: join(tempDir, 'test.chronicle'),
      membrane: membrane.asMembrane(),
      agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'Assist.' }],
      modules: [new HangingToolModule()],
    });
    try {
      framework.start();
      const created = await framework.createEphemeralAgent({ name: 'eph', model: 'test-model', systemPrompt: 'Run.' });
      created.contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]);
      let outcome = 'pending';
      const run = framework.runEphemeralToCompletion(created.agent, created.contextManager)
        .then(() => { outcome = 'resolved'; }, (e) => { outcome = `rejected: ${(e as Error).message}`; });
      const start = Date.now();
      while (created.agent.state.status !== 'waiting_for_tools') {
        if (Date.now() - start > 3000) throw new Error('never reached waiting_for_tools');
        await new Promise((r) => setTimeout(r, 10));
      }
      await framework.stop();
      await Promise.race([run, new Promise((r) => setTimeout(r, 1500))]);
      console.log('OUTCOME after stop()+1.5s:', outcome, '| agent status:', created.agent.state.status);
      assert.notEqual(outcome, 'pending', 'ephemeral run still pending after stop()');
    } finally {
      rmSync(tempDir, { recursive: true, force: true });
    }
  });
});

Method

Astra ran read-only with no network (9m44s, 74 commands): it could not install deps, so its probes were in-memory extractions of stop/driveStream/ephemeral methods, and it read a local membrane checkout at 0.5.78 — which is how the pre-0.5.81 hole in #2 surfaced. Claude covered GitHub state, the fork fetch, connectome-host main (558095e), membrane main, and a real npm install + tsx repro with a control at 0d37888. Found independently by both: #1, #2 (host callers), #4, #5. Astra-only: #3, #6, #9, the membrane-floor hole. Claude-only: the membrane fix version, #8.

— Reviewed by GPT-6 Astra via OpenAI Codex, and Claude (Claude Code), on Anarchid's account, disclosed per convention.

slimepriestess added a commit to slimepriestess/agent-framework that referenced this pull request Sep 22, 2026
…wn is neither

Supersedes anima-research#134 (Lari), reworked on current main after Sol's 2026-09-21
review of that head plus the stacked shutdown fix.

Cancel (Stop button, agent.cancelStream(), framework.abortInference()):
driveStream's untracked `aborted` path now splits on membrane's wire
reason. 'user' (>= 0.5.81: the request's signal was aborted) is a
deliberate stop — inference:aborted instead of inference:exhausted, no
failure streak / ops alert / failures.log, lifecycle 'aborted', and a
[turn-interrupted] marker that names the act and not an actor (the wire
reason says the call happened, not who called) and says nothing about
delivery (earlier rounds may already have been live-routed). Any other
reason keeps the failure pipeline. Callers can hand over their own
provenance: Agent.cancelStream(reason) stores it, the driver collects it
with takeCancelReason(), and the trace + marker metadata carry it.
framework.abortInference() no longer emits its own inference:aborted for a
streaming agent, so a host abort is traced once with the caller's reason.

Shutdown: AgentFramework.stop() records 'shutdown' in
frameworkCancelledStreams before cancelling, and both abort twins
(`aborted` and `error`) settle it like quiesce_abandoned: abortAgentScript,
reset, settleAgent, inference-log terminal, one inference:aborted with
reason 'shutdown', no marker. The settle is what lets an in-flight
runEphemeralToCompletion reject now rather than after its 15-minute idle
watchdog (Sol's repro, lifted as a test).

No-locus route failures read [send-undeliverable] … had no channel to go
to, not a Discord failure to "the channel"; `kind` stays stable. The
gate-buffer logging from anima-research#134 is dropped: main already counts and
throttles evictions (anima-research#122).

Membrane floor ^0.5.81: before it, 'user' could come from a broad abort
catch on a genuine provider error.

Tests: user-interrupt-not-failure (cancel → neutral marker + one aborted
trace; abortInference(reason) → exactly one trace with that reason;
provider abort reason 'error' → failure pipeline) and
shutdown-not-user-attributed (resident: no marker, reason shutdown,
settled; ephemeral: rejects promptly with the shutdown terminal). Against
main: 4 of 5 red. Mutations: no-settle shutdown, actor/delivery-claiming
marker text, duplicate abortInference trace — each fails its own test.
Full suite 963/0.

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

Copy link
Copy Markdown
Contributor

Superseded by #172, per Lari's word in person today (2026-09-22): her branch, the stacked shutdown fix, and Sol's 9/21 list, rebuilt on current main. Parts 1 and 2 carry over reworked; part 3 (gate-buffer log) is dropped because main already throttles evictions (#122). Not closing this one myself — that's Lari's or antra's call.

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.

3 participants