Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
063151b
fix(connector): track transport liveness
Aug 29, 2026
bf66676
test(connector): fix transport mutations
Aug 29, 2026
daa677d
refactor(connector): share transport state type
Aug 29, 2026
a2fc6b1
fix(connector): fence stopped state
Aug 29, 2026
730a76a
fix(connector): retain drop diagnostics
Aug 29, 2026
d473051
fix(connector): close stopped start races
Aug 29, 2026
ef58a30
test(connector): refresh issue mutation
Aug 29, 2026
6ca9b46
test(connector): prove initial stop cleanup (#975)
Aug 29, 2026
5c63adb
test(connector): clean leaked repro resources
Aug 29, 2026
5f3c26c
fix(connector): report manual reconnect gaps
Aug 29, 2026
ec7f3a3
test(connector): refresh completion marker
Aug 29, 2026
9232aec
test(connector): prove status error reachability
Aug 29, 2026
9160167
test(connector): classify status catch reach
Aug 29, 2026
d90ec4a
Merge remote-tracking branch 'origin/main' into conn/phase1a-transpor…
davidfarah2003 Aug 29, 2026
63ec804
ci: keep this branch's suites at the tail after the merge
davidfarah2003 Aug 29, 2026
4e80261
feat(connector-core): report connection status
Aug 29, 2026
0b2f5bd
test(connector-core): complete mutation run
Aug 29, 2026
f6b6ee8
feat(connector-core): report connection status as five distinct states
davidfarah2003 Aug 29, 2026
0e04345
test(connector): anchor the transport mutations on code
davidfarah2003 Aug 29, 2026
6a69df0
Merge remote-tracking branch 'origin/conn/phase1a-transport-liveness'…
davidfarah2003 Aug 29, 2026
45b046d
Merge remote-tracking branch 'origin/main' into conn/phase1a-transpor…
davidfarah2003 Aug 29, 2026
653aedd
ci: append the transport-liveness suites at the tail after merging main
davidfarah2003 Aug 29, 2026
be3d84c
test(connector-core): assert transport is cleared by a terminal close
davidfarah2003 Aug 29, 2026
b6179d7
Merge branch 'conn/phase1a-transport-liveness' into conn/phase1-conne…
davidfarah2003 Aug 29, 2026
655f06f
Merge remote-tracking branch 'origin/main' into conn/phase1a-transpor…
davidfarah2003 Aug 29, 2026
48f3b5e
ci: keep the transport-liveness entries at the tail after merging main
davidfarah2003 Aug 29, 2026
441a657
Merge remote-tracking branch 'origin/main' into conn/phase1a-transpor…
davidfarah2003 Aug 29, 2026
e34f714
ci: keep the transport-liveness entries at the tail after merging main
davidfarah2003 Aug 29, 2026
48bfcb9
test(connector-core): anchor the terminal-close and stop transport edges
davidfarah2003 Aug 29, 2026
3165186
test(connector-core): prove a real terminal close clears transport
davidfarah2003 Aug 29, 2026
63c4eb6
Revert "test(connector-core): prove a real terminal close clears tran…
davidfarah2003 Aug 29, 2026
a05319c
fix(core): a stop landing mid-bind no longer announces the connection
Aug 29, 2026
99c8414
test(connector-core): give the mid-bind race cell a loaded-runner budget
Aug 29, 2026
5ffeb15
Merge remote-tracking branch 'origin/main' into conn/phase1a-transpor…
Aug 29, 2026
eecdb01
chore(ci): move the transport suites to the true tail of ci-suites.txt
Aug 29, 2026
50c8a7f
Merge remote-tracking branch 'origin/main' into conn/phase1a-transpor…
Aug 29, 2026
58f2782
chore(ci): re-tail the transport suites after merging main
Aug 29, 2026
a3fcbf5
docs(connector-core): stop overclaiming why armPlane3 is the gate
Aug 29, 2026
7b3aef5
docs(core): record what the stopped guard is measured for, and when t…
Aug 29, 2026
a576eb8
fix(core): a stop during a pending dial no longer seeds transport live
Aug 29, 2026
b5805ca
chore: note the transport seed fence in the changeset
Aug 29, 2026
904ac46
Merge remote-tracking branch 'origin/main' into HEAD
Aug 30, 2026
ed30913
ci: re-tail transport suites after merging main
Aug 30, 2026
46acf2c
Merge pull request #1002 from Cotal-AI/conn/phase1-connection-status-v2
davidfarah2003 Aug 30, 2026
8f261d8
Merge origin/main into conn/phase1a-transport-liveness
davidfarah2003 Aug 30, 2026
38520a6
Merge origin/main into conn/phase1a-transport-liveness
davidfarah2003 Aug 30, 2026
e81bb30
Merge origin/main into conn/phase1a-transport-liveness
davidfarah2003 Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/connection-status-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cotal-ai/connector-core": minor
---

Add `cotal_connection_status`, a read-only MCP tool reporting this session's mesh connection as one
of five distinct states: ready, degraded (bound while the transport underneath is down), connecting
(transport live, bind unfinished), disconnected, and stopped (shut down deliberately, which is not a
fault). It also reports the raw facts the state is derived from, the buffered inbox count, and the
measured last successful inbox drain. A retained failure is reported as the current reason only while
it is one, and as a post-mortem on a stopped session.

`MeshAgent` gains `stopping` and `connectionState`. Without `stopping` a deliberate shutdown and a
lost connection are indistinguishable, because `stop()` clears readiness and transport together.
17 changes: 17 additions & 0 deletions .changeset/transport-liveness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@cotal-ai/core": minor
"@cotal-ai/connector-core": minor
---

Expose raw NATS transport liveness separately from full endpoint readiness. Connector sessions now
track transient disconnect and reconnect edges without flapping readiness, ignore stale events from
replaced connection epochs, and clear both states on stop. Connection issues remain scoped to pre-bind
readiness failures, clear on a successful bind, and survive stop for post-mortem diagnosis.

An endpoint stopped while its bind is still in flight also no longer announces that connection.
The bind's own teardown already discarded it, but the readiness event was emitted first, so any
listener on the endpoint was left holding a connected edge that nothing ever corrected.

The same applies to the transport seed, which reaches further back. It fires as soon as the dial
returns, well before the bind completes, so a stop arriving while the dial was still pending had a
stopped endpoint announce a live transport it never had. Both edges are now guarded.
9 changes: 9 additions & 0 deletions bin/smoke/ci-suites.txt
Original file line number Diff line number Diff line change
Expand Up @@ -713,3 +713,12 @@ smoke:web-presence-view
# sequence 1. Discriminator is drainWindow minStart, not the returned page. Appended so
# every existing shard assignment remains unchanged.
smoke:sparse-history-walk
# Raw NATS transport state is separate from full endpoint readiness. The deterministic suite grades
# epoch staleness and state contracts; its companion owns a throwaway broker and proves real
# disconnect/reconnect delivery. Appended so every existing shard assignment remains unchanged.
smoke:transport-liveness
smoke:transport-liveness:broker
# The connection status tool reaches MeshAgent state through a real in-memory MCP server and client,
# including a measured successful inbox drain. Appended so every existing shard assignment remains
# unchanged.
smoke:connection-status
9 changes: 9 additions & 0 deletions docs/connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and delivery model ([MCP tools](mcp-tools.md)). They differ in how they bind to
and which spawn features are wired. Anything unwired **fails loud**: a flag a connector does
not support throws; nothing silently degrades.

Connectors track raw NATS transport liveness separately from endpoint readiness. A short broker
disconnect marks the transport down until nats.js reconnects, without claiming that the connector's
full Cotal bind was torn down and rebuilt. A clean connector stop clears both states locally.
The endpoint `transport` event reports edges and is not replayed to listeners attached later. A
connector that needs current state reads its `MeshAgent.transportConnected` value, then listens for
later edges.
`MeshAgent.connectionIssue` records the latest failure before a successful bind. A later bind clears
it; stopping preserves it so an operator can inspect why the session never connected or last dropped.

| | [Claude Code](connect-claude.md) | [OpenCode](connect-opencode.md) | [Codex](connect-codex.md) | [Hermes](connect-hermes.md) | [Jcode](connect-jcode.md) | [pi](connect-pi.md) |
|---|---|---|---|---|---|---|
| Maturity | stable | beta | beta | alpha | beta | alpha |
Expand Down
13 changes: 13 additions & 0 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The tools are defined once, platform-neutrally, in `@cotal-ai/connector-core` an
| Tool | Does | Side-effect |
|---|---|---|
| [`cotal_orientation`](#cotalorientation) | orient (who you are & what you can do) | read-only |
| [`cotal_connection_status`](#cotalconnectionstatus) | connection status | read-only |
| [`cotal_docs`](#cotaldocs) | read the docs (version-exact) | read-only |
| [`cotal_roster`](#cotalroster) | who's present | read-only |
| [`cotal_inbox`](#cotalinbox) | read incoming messages | clears only the messages it returns (nothing at all when peek is true) |
Expand Down Expand Up @@ -41,6 +42,18 @@ Your orientation card: who you are (name/role/space), the channels you can read

No arguments.

## `cotal_connection_status`

*connection status*

Report this session's mesh connection as one of five states, plus the raw facts it is derived from. `ready` is bound with a live transport. `degraded` is bound while the transport underneath is DOWN, so sends queue or fail until the client reconnects; this is the state that needs attention. `connecting` is a live transport whose Cotal bind has not finished. `disconnected` is neither. `stopped` means this session was shut down deliberately and is terminal, which is not a fault. Also reports the buffered inbox count and the time of the latest successful non-empty inbox drain when one has occurred. A retained failure is reported as `connectionIssue` while it is the CURRENT reason, and as `lastConnectionIssue` on a stopped session, where it is a post-mortem rather than a live problem. Read-only and local: it reads this session's MeshAgent directly and does not call the manager or the broker.

- **Side-effect:** read-only.
- **Available:** always.
- Reads this session's MeshAgent directly. `lastDrainedAt` is omitted until a non-empty inbox drain has successfully committed.

No arguments.

## `cotal_docs`

*read the docs (version-exact)*
Expand Down
254 changes: 254 additions & 0 deletions extensions/connector-core/smoke/connection-status.smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/**
* THE CONNECTION STATUS TOOL REPORTS THIS SESSION'S LIVE STATE, RATHER THAN ASSUMING IT.
*
* A silent inbox has two meanings: nothing arrived, or this session is not connected. The tool must
* keep those apart using MeshAgent's own state. This suite reaches it through a real MCP server and
* client, not by calling the tool helper directly, so it grades the registered route as an agent
* invokes it. The broker address is inert: MeshAgent is constructed but never started.
*
* MUTATION LEDGER, predicted before the run. Six mutations, because the state a caller acts on is
* derived from three facts and one mutation on the derivation would be killed by whichever cell ran
* first, leaving every other state ungraded.
*
* M1 replaces MeshAgent's `connected` getter with the constant false.
* IN "the real MCP route reports the MeshAgent's live connected=true state"
* ALSO "bound with the transport down reports degraded, not connected and not disconnected",
* which asserts the REPORTED `connected` is true. Predicted, not a surprise.
* OUT every cell that asserts only on `state`: `connectionState` reads the private field rather
* than this getter, so the derived state is unmoved by M1. Ready, connecting, disconnected and
* both stopped cells stay green.
*
* M2 deletes the `_stopping` branch from `connectionState`.
* IN "a deliberately stopped session reports stopped rather than disconnected"
* ALSO "a stopped session reports its retained failure as a post-mortem, not as a current issue",
* because the issue key is chosen from the state. Predicted, not a surprise.
* OUT ready, degraded, connecting and disconnected: none of them stages `_stopping`.
*
* M3 collapses `degraded` into `ready`.
* IN "bound with the transport down reports degraded, not connected and not disconnected"
* OUT ready is already ready; connecting and disconnected stage `_connected` false and never
* reach the mutated branch; stopped returns before it.
*
* M4 collapses `connecting` into `disconnected`.
* IN "a live transport whose bind has not finished reports connecting"
* OUT disconnected expects that value anyway; ready and degraded return before this line;
* stopped returns first.
*
* M5 drops the stopped scoping on the reported issue in tool-specs.
* IN "a stopped session reports its retained failure as a post-mortem, not as a current issue"
* OUT the disconnected cell, which expects `connectionIssue` and gets it under the mutant too;
* every cell that stages no issue at all.
*
* M6 replaces the `transportConnected` getter with the constant true.
* IN "bound with the transport down reports degraded, not connected and not disconnected"
* OUT the derived state is computed from the private field, so `state` is unaffected everywhere.
* Only cells asserting the REPORTED fact move. This is deliberate: it proves the raw facts
* come from live getters rather than being back-derived from the state, which would make them
* useless to a caller wanting to check our reading.
*
* M7 replaces the reported `stopping` fact with the constant false.
* IN "the reported facts distinguish stopped from disconnected, which agree on both other facts"
* OUT every cell asserting only on `state`: the derivation reads the private field, so the state
* itself is unmoved. Only the reported fact breaks, which is the point.
*
* WHAT THIS SUITE DOES NOT CLAIM. Every state is staged by writing MeshAgent's private fields, so
* these cells prove the tool REPORTS each state distinctly. They do not prove the endpoint reaches
* each combination. That is proved separately: the transport-liveness broker companion drives real
* disconnect and reconnect edges against a real broker, and the `connecting` window exists by
* construction, since the endpoint emits transport=true when connect() returns while the Cotal bind
* below is still in progress. Reachability is argued there and deliberately not claimed here.
*
* Named gap: no broker connection is opened, so this suite does not prove CotalEndpoint emits the
* connection event. Existing endpoint suites own that source. It proves this tool reports the state
* MeshAgent holds and that a real MCP call reaches it.
*
* Harness correction before the graded rerun: the first mutation attempt used the green success
* summary as `completionMarker`. That correctly went absent on red and made the proof inconclusive.
* The suite now prints a separate completion line after all cells on both outcomes; the marker names
* that line rather than a success condition.
*
* Run: pnpm smoke:connection-status
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { MeshAgent, type InboxItem } from "../src/agent.js";
import type { AgentConfig } from "../src/config.js";
import { registerCotalTools } from "../src/tools.js";

let pass = 0;
let fail = 0;
const check = (name: string, cond: boolean, extra?: unknown): void => {
if (cond) { pass++; console.log(` \u2713 ${name}`); }
else { fail++; console.log(` \u2717 FAIL: ${name}`, extra ?? ""); }
};

const config: AgentConfig = {
space: "connection-status",
name: "status-agent",
servers: "nats://127.0.0.1:1",
kind: "agent",
tls: false,
subscribe: [],
allowSubscribe: [],
allowPublish: [],
};
const agent = new MeshAgent(config);
// Both liveness facts, deliberately. Staging only `_connected` leaves `_transportConnected` false,
// which is the DEGRADED state, so a setup that sets one and calls the session healthy is staging the
// very combination this tool exists to tell apart.
type Stage = { _connected: boolean; _transportConnected: boolean; _stopping: boolean; lastConnectionError?: string };
const stage = agent as unknown as Stage;
stage._connected = true;
stage._transportConnected = true;

const acked: string[] = [];
const item = (id: string): InboxItem => ({
id,
recvKey: id,
ts: Date.now(),
fromId: `peer-${id}`,
fromName: `peer-${id}`,
kind: "dm",
mentionsMe: false,
historical: false,
text: `message ${id}`,
});
(agent as unknown as { inbox: Array<{ item: InboxItem; ack: () => void; pullOnly: boolean }> }).inbox = [
{ item: item("one"), ack: () => acked.push("one"), pullOnly: false },
{ item: item("two"), ack: () => acked.push("two"), pullOnly: false },
];

const server = new McpServer({ name: "connection-status-smoke", version: "0.0.0" });
registerCotalTools(server, agent, config, "smoke");
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "connection-status-client", version: "0.0.0" });
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const listed = await client.listTools();
const statusDecl = listed.tools.find((tool) => tool.name === "cotal_connection_status");
check(
"the status tool is published with a CLOSED empty input schema",
!!statusDecl && Object.keys(statusDecl.inputSchema?.properties ?? {}).length === 0 &&
(statusDecl.inputSchema as { additionalProperties?: unknown } | undefined)?.additionalProperties === false,
statusDecl?.inputSchema,
);

let refused = "";
try {
const result = await client.callTool({ name: "cotal_connection_status", arguments: { owner: "attacker" } });
refused = JSON.stringify(result);
} catch (error) {
refused = String(error);
}
check(
"unknown input is refused before the status route executes",
refused.includes("owner") && refused.includes("unrecognized_keys"),
refused,
);

const text = async (name: string): Promise<string> => {
const result = await client.callTool({ name, arguments: {} });
const first = result.content[0];
if (!first || first.type !== "text") throw new Error(`${name} returned no text`);
return first.text;
};
const status = async (): Promise<Record<string, unknown>> => JSON.parse(await text("cotal_connection_status"));

const initial = await status();
check("the real MCP route reports the MeshAgent's live connected=true state", initial.connected === true, initial);
check("the first status has no synthesized lastDrainedAt", !("lastDrainedAt" in initial), initial);
check("the status route reports the live buffered count before the drain", initial.bufferedCount === 2, initial);

const beforeDrain = Date.now();
await text("cotal_inbox");
const afterDrain = Date.now();
check(
"a real inbox call clears the two buffered deliveries",
agent.inboxCount() === 0 && acked.join(",") === "one,two",
{ buffered: agent.inboxCount(), acked },
);

const drained = await status();
const drainedAt = typeof drained.lastDrainedAt === "string" ? Date.parse(drained.lastDrainedAt) : Number.NaN;
check(
"lastDrainedAt is measured by that successful non-empty inbox drain",
Number.isFinite(drainedAt) && drainedAt >= beforeDrain && drainedAt <= afterDrain,
{ drainedAt: drained.lastDrainedAt, beforeDrain, afterDrain },
);
check("the status route reports the live buffered count after the drain", drained.bufferedCount === 0, drained);

check("a bound session with a live transport reports ready", (await status()).state === "ready", await status());

// DEGRADED: bound, transport down. The single boolean this tool used to report was FALSE here, on
// the one row that actually needs attention, because it was derived from `connected` alone.
stage._transportConnected = false;
const degraded = await status();
check(
"bound with the transport down reports degraded, not connected and not disconnected",
degraded.state === "degraded" && degraded.connected === true && degraded.transportConnected === false,
degraded,
);

// CONNECTING: the transport is live before the Cotal bind finishes. The endpoint creates this
// window deliberately, emitting transport=true when connect() returns while the bind is still in
// progress, so this is a real state rather than one invented to fill the table.
stage._connected = false;
stage._transportConnected = true;
const connecting = await status();
check(
"a live transport whose bind has not finished reports connecting",
connecting.state === "connecting" && connecting.connected === false && connecting.transportConnected === true,
connecting,
);

// DISCONNECTED, carrying the reason as a CURRENT problem.
stage._transportConnected = false;
stage.lastConnectionError = "socket closed";
const down = await status();
check(
"neither bound nor transported reports disconnected, with the reason as a current issue",
down.state === "disconnected" && down.connectionIssue === "socket closed" && !("lastConnectionIssue" in down),
down,
);

// STOPPED: terminal and NOT a fault. stop() clears both liveness flags, so without `stopping` this
// is indistinguishable from the disconnected row above. The retained issue is a post-mortem here,
// and reporting it under the same key would tell a reader a cleanly stopped session is broken.
stage._stopping = true;
const stopped = await status();
check(
"a deliberately stopped session reports stopped rather than disconnected",
stopped.state === "stopped",
stopped,
);
check(
"a stopped session reports its retained failure as a post-mortem, not as a current issue",
stopped.lastConnectionIssue === "socket closed" && !("connectionIssue" in stopped),
stopped,
);

// The reported facts must be able to REPRODUCE the state, or they are decoration rather than a
// check on our derivation. Stopped and disconnected both read connected=false and
// transportConnected=false, so `stopping` is the only fact that separates them.
check(
"the reported facts distinguish stopped from disconnected, which agree on both other facts",
down.connected === false &&
down.transportConnected === false &&
down.stopping === false &&
stopped.connected === false &&
stopped.transportConnected === false &&
stopped.stopping === true,
{ down, stopped },
);

await Promise.all([client.close(), server.close()]);

const EXPECTED_CELLS = 15;
const ran = pass + fail;
console.log(`\n${fail === 0 ? "PASS" : "FAIL"}: ${pass} passed, ${fail} failed`);
console.log(`SUITE COMPLETE: ${ran} cells`);
if (ran !== EXPECTED_CELLS) {
console.log(`SUITE INCOMPLETE: ran ${ran} of ${EXPECTED_CELLS} cells; a partial run is not a pass`);
process.exitCode = 1;
} else process.exitCode = fail === 0 ? 0 : 1;
Loading
Loading