@nanobpm/nano-workforce 0.176.1 → 0.178.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ // nano-workforce — the agentic-channel `claim` / `release` job-ownership family (#713).
2
+ //
3
+ // H0 (#143) seam plug-in: ONE new file under `app/agentic/families/`, discovered by the loader's
4
+ // `*.family.ts` convention and mounted by the seam — it never edits `main.ts`, `drainAndExit`, or any
5
+ // shared boot line. It owns the `claim` (wire code 8) and `release` (wire code 9) message families —
6
+ // the explicit job-ownership frames nano-ide#542 appended to `@nanobpm/agentic` — attaching each
7
+ // handler through the hub's `registerFamilyHandler` seam (one family, one owning module), never a
8
+ // shared dispatch switch.
9
+ //
10
+ // What it gives the fleet: a first-class {@link ClaimRegistry} (`instance → set<jobKey>`) that becomes
11
+ // the AUTHORITATIVE source the supply snapshot reads for `jobKeys` — replacing the fragile
12
+ // relay-derived visibility. Each frame carries its OWNING `instance` EXPLICITLY, so attribution reads
13
+ // the frame, NOT the connection id (`conn.id`); that is what lets one per-host supervisor multiplex N
14
+ // distinct workers' ownership over a single connection. On a reconnect the supervisor re-`register`s
15
+ // every worker and re-`claim`s every active jobKey, and the (idempotent) claim handler rebuilds the
16
+ // registry from that resync.
17
+ //
18
+ // Liveness: a worker holding a claim reads "working" even with ZERO transcript — visibility no longer
19
+ // depends on terminal bytes landing/correlating. A bounded-memory maintenance tick reconciles the
20
+ // claim registry against the live presence set so a departed supervisor's claims are reclaimed (the
21
+ // `release` frame is the primary clear; this is the safety net for an unclean drop).
22
+ //
23
+ // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
24
+ // is untouched; ADVISORY — the registry is a read-only visibility source and NEVER gates a BPMN
25
+ // sequence flow.
26
+ import type { HubConnection } from "@nanobpm/agentic/channel";
27
+ import { type Frame, validatePayload } from "@nanobpm/agentic/protocol";
28
+ import { ClaimRegistry, setCurrentClaimRegistry } from "../claim-registry.ts";
29
+ import type { AgenticContext, AgenticFamily } from "../registry.ts";
30
+ import { currentPresenceRegistry } from "./presence.family.ts";
31
+
32
+ /** The message-family names this module owns (the two ownership frames). */
33
+ export const CLAIM_FAMILY = "claim";
34
+ export const RELEASE_FAMILY = "release";
35
+
36
+ /** The reconcile tick runs at a third of the presence TTL — matching the presence-maintenance cadence. */
37
+ const SWEEP_DIVISOR = 3;
38
+ /** Fallback reconcile cadence when no presence registry is mounted to source a TTL. */
39
+ const DEFAULT_RECONCILE_MS = 10_000;
40
+
41
+ /** Read a string property from an unknown frame payload, or undefined when absent / non-string. */
42
+ function readString(value: unknown, key: string): string | undefined {
43
+ if (!value || typeof value !== "object") return undefined;
44
+ if (!Object.hasOwn(value, key)) return undefined;
45
+ const field = Object.getOwnPropertyDescriptor(value, key)?.value;
46
+ return typeof field === "string" ? field : undefined;
47
+ }
48
+
49
+ interface MountState {
50
+ readonly registry: ClaimRegistry;
51
+ timer: ReturnType<typeof setTimeout> | undefined;
52
+ stopped: boolean;
53
+ }
54
+
55
+ let state: MountState | undefined;
56
+
57
+ /**
58
+ * The `claim` / `release` family module. `mount` installs a fresh {@link ClaimRegistry} as the
59
+ * process-wide singleton, attaches the two frame handlers via the S1 seam, and starts ONE bounded
60
+ * reconcile tick. `teardown` stops the tick, detaches nothing (the hub owns handler lifetime for the
61
+ * remount-guarded test path) and clears the singleton.
62
+ */
63
+ export const family: AgenticFamily = {
64
+ name: CLAIM_FAMILY,
65
+
66
+ mount(ctx: AgenticContext): void {
67
+ const registry = new ClaimRegistry();
68
+ setCurrentClaimRegistry(registry);
69
+
70
+ // `claim` opens the ownership window; `release` closes it. Attribution reads the frame's EXPLICIT
71
+ // `instance` — never `conn.id` — so one connection can carry many instances' ownership frames. A
72
+ // malformed payload is rejected before it touches the registry (advisory: logged, connection
73
+ // kept). Both mutations are idempotent, matching the wire contract.
74
+ ctx.hub.registerFamilyHandler(CLAIM_FAMILY, (frame: Frame, _conn: HubConnection) => {
75
+ const result = validatePayload(CLAIM_FAMILY, frame.payload);
76
+ if (!result.ok) {
77
+ ctx.log.warn("agentic claim: malformed payload", { errors: result.errors.map((e) => e.code) });
78
+ return;
79
+ }
80
+ const instance = readString(frame.payload, "instance");
81
+ const jobKey = readString(frame.payload, "jobKey");
82
+ if (!instance || !jobKey) return;
83
+ registry.claim(instance, jobKey);
84
+ });
85
+
86
+ ctx.hub.registerFamilyHandler(RELEASE_FAMILY, (frame: Frame, _conn: HubConnection) => {
87
+ const result = validatePayload(RELEASE_FAMILY, frame.payload);
88
+ if (!result.ok) {
89
+ ctx.log.warn("agentic release: malformed payload", { errors: result.errors.map((e) => e.code) });
90
+ return;
91
+ }
92
+ const instance = readString(frame.payload, "instance");
93
+ const jobKey = readString(frame.payload, "jobKey");
94
+ if (!instance || !jobKey) return;
95
+ registry.release(instance, jobKey);
96
+ });
97
+
98
+ // Bounded-memory reconcile: drop claims whose owning instance no longer has a presence row (a
99
+ // dropped supervisor / aged-out worker). BOTH the drop-set AND the cadence are recomputed per
100
+ // tick from the live presence registry via a self-rescheduling timer, so the reconcile is truly
101
+ // independent of family mount order: whether presence mounts before or after this family, once it
102
+ // is present each tick reclaims absent instances' claims AND adjusts its cadence to the real TTL
103
+ // (a fixed-at-mount interval would stay pinned to the fallback cadence when claim mounts first).
104
+ // Advisory — a fault is logged, never thrown, and the tick never keeps the process alive on its
105
+ // own.
106
+ const reconcileMs = (): number => {
107
+ const presenceTtl = currentPresenceRegistry()?.ttlMs;
108
+ return Math.max(1, Math.floor((presenceTtl ?? DEFAULT_RECONCILE_MS) / SWEEP_DIVISOR));
109
+ };
110
+ const schedule = (): void => {
111
+ if (!state || state.stopped) return;
112
+ const timer = setTimeout(tick, reconcileMs());
113
+ timer.unref?.();
114
+ state.timer = timer;
115
+ };
116
+ const tick = () => {
117
+ try {
118
+ const presence = currentPresenceRegistry();
119
+ if (presence) {
120
+ const present = new Set(presence.registeredWorkers().map((w) => w.instance));
121
+ const released = registry.reconcile(present);
122
+ if (released.length > 0) {
123
+ ctx.log.info("agentic claim reconcile released absent instances", { released: released.length });
124
+ }
125
+ }
126
+ // else: no presence source → keep claims until one mounts (resync repopulates)
127
+ } catch (err) {
128
+ ctx.log.warn("agentic claim reconcile failed", { err: String(err) });
129
+ }
130
+ schedule();
131
+ };
132
+
133
+ state = { registry, timer: undefined, stopped: false };
134
+ schedule();
135
+ ctx.log.info("agentic claim family mounted", { families: [CLAIM_FAMILY, RELEASE_FAMILY] });
136
+ },
137
+
138
+ teardown(): void {
139
+ if (state) {
140
+ state.stopped = true;
141
+ if (state.timer !== undefined) clearTimeout(state.timer);
142
+ }
143
+ state = undefined;
144
+ setCurrentClaimRegistry(undefined);
145
+ },
146
+ };
147
+
148
+ export default family;
@@ -0,0 +1,63 @@
1
+ // Authoring guard for the curated MCP tool subset (`app/mcpToolSurface.ts`, issue #715).
2
+ //
3
+ // The curated `CURATED_MCP_TOOLS` allowlist is the tractable subset a client imports instead of
4
+ // `["*"]`. `e2e/mcp-tractability.e2e.ts` proves every entry projects onto the LIVE surface, but that
5
+ // needs a booted instance. This fast unit guard checks the same list against the SAME framework
6
+ // walker the runtime MCP projector uses (`parseSpec` + `collectOperations`) so an authoring typo —
7
+ // a curated app-tool name that is not an `openapi.yaml` operationId, or one accidentally `x-mcp`-
8
+ // excluded — fails CI in `npm test` without booting anything. Framework `urban_debug_*` tools are
9
+ // not `openapi.yaml` operations, so they are validated by their reserved prefix instead.
10
+ //
11
+ // Derivation over duplication (AGENTS.md): the exclusion/projection rule is NOT re-implemented here —
12
+ // it is read from the framework walker's `mcpExcluded` flag, the exact rule the runtime honours.
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { test } from "node:test";
17
+ import { collectOperations, parseSpec } from "@nanobpm/urban/toolkit";
18
+ import { CURATED_MCP_TOOLS, FRAMEWORK_TOOL_PREFIX } from "../app/mcpToolSurface.ts";
19
+ import { assert } from "#test-assert";
20
+
21
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
22
+ const SPEC_PATH = join(REPO_ROOT, "openapi.yaml");
23
+
24
+ function projectedAppTools(): Set<string> {
25
+ return new Set(
26
+ collectOperations(parseSpec(readFileSync(SPEC_PATH, "utf8")))
27
+ .filter((op) => !op.mcpExcluded)
28
+ .map((op) => op.operationId),
29
+ );
30
+ }
31
+
32
+ test("every curated APP tool is a projected (non-x-mcp) openapi operation", () => {
33
+ const projected = projectedAppTools();
34
+ for (const name of CURATED_MCP_TOOLS) {
35
+ if (name.startsWith(FRAMEWORK_TOOL_PREFIX)) continue; // framework tool — validated by prefix below
36
+ assert(
37
+ projected.has(name),
38
+ `curated tool "${name}" is not a projected openapi operation — it is missing from openapi.yaml ` +
39
+ `or has been x-mcp-excluded. A client importing the curated allowlist would silently not get it.`,
40
+ );
41
+ }
42
+ });
43
+
44
+ test("every curated FRAMEWORK tool carries the reserved urban_debug_ prefix", () => {
45
+ const projected = projectedAppTools();
46
+ for (const name of CURATED_MCP_TOOLS) {
47
+ if (!name.startsWith(FRAMEWORK_TOOL_PREFIX)) continue;
48
+ // A framework name must NOT also be an app operationId (that would be a namespace collision the
49
+ // runtime reserves against) — it is owned entirely by the runtime's engine-debug family.
50
+ assert(
51
+ !projected.has(name),
52
+ `curated framework tool "${name}" unexpectedly collides with an openapi operationId.`,
53
+ );
54
+ }
55
+ });
56
+
57
+ test("the curated subset has no duplicate entries", () => {
58
+ const seen = new Set<string>();
59
+ for (const name of CURATED_MCP_TOOLS) {
60
+ assert(!seen.has(name), `CURATED_MCP_TOOLS lists "${name}" more than once.`);
61
+ seen.add(name);
62
+ }
63
+ });
@@ -0,0 +1,112 @@
1
+ // Canonical source of truth for the workforce MCP tool SURFACE budget and the curated driving
2
+ // subset (issue #715, epic #605 "tractable surface").
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ===============
6
+ // The runtime-served MCP surface (`/app/mcp`, ADR 0067) projects EVERY non-`x-mcp` `openapi.yaml`
7
+ // operation — plus the framework-owned `urban_debug_*` engine tools — into a tool. Measured against
8
+ // the deployed surface (issue #715) that is **56 tools / ~79 KB of `tools/list`**: large enough that
9
+ // an agent harness (Copilot CLI and others) DEFERS the whole set behind a tool-search gate, and a
10
+ // client config of `"tools": ["*"]` imports all 56 eagerly. That is the #605 "tractable surface"
11
+ // problem, quantified.
12
+ //
13
+ // The workforce-side lever is TWO-fold, and both live here as one source of truth:
14
+ //
15
+ // 1. A **budget** on the full projected surface (count + bytes), so the surface can only ever
16
+ // SHRINK below these ceilings — a new door that pushes it over fails CI (`e2e/mcp-tractability.e2e.ts`).
17
+ // The transport-level count reduction (gating rarely-used framework `urban_debug_*` tools
18
+ // behind a mode) lives in the urban runtime and is tracked upstream (nano-ide#488); this budget
19
+ // pins the workforce-visible number so it cannot regress while that lands.
20
+ // 2. A **curated subset** — the tools an agent actually drives/reads with day to day — that a
21
+ // client imports via its MCP-server `"tools"` allowlist INSTEAD of `["*"]`, so the eagerly-loaded
22
+ // set is materially smaller than the full surface and stays under the harness deferral threshold.
23
+ // This is the "documented curated subset" the issue's acceptance allows.
24
+ //
25
+ // DERIVATION OVER DUPLICATION (AGENTS.md)
26
+ // =======================================
27
+ // This list is the ONE authored source. `e2e/mcp-tractability.e2e.ts` asserts every name here
28
+ // actually projects onto the LIVE `/app/mcp` surface (so a curated entry can never go dead), and
29
+ // `scripts/sync-mcp-curated.ts` renders it verbatim into the runbook (`docs/mcp-runbook.md`) — a
30
+ // drift test under `npm test` (`scripts/sync-mcp-curated.test.ts`, which CI runs) and
31
+ // `npm run sync:mcp-curated:check` both fail on any drift. The served "Connect over MCP" page
32
+ // (`pages/mcp.page.json`) carries the concept as prose and points here + at the runbook, so there is
33
+ // no second enumerated copy to drift. Never hand-edit the curated `tools` block in the runbook; edit
34
+ // HERE and re-run `npm run sync:mcp-curated`.
35
+
36
+ /**
37
+ * The curated driving/reading subset an MCP client should import via its server entry's `"tools"`
38
+ * allowlist instead of `["*"]`. Grouped by intent in authoring order; the union is what a workforce
39
+ * operator/agent needs to drive work, read status, answer escalations, and triage a wedged instance —
40
+ * WITHOUT eagerly loading the whole 56-tool surface. Every name is asserted to project onto the live
41
+ * surface by `e2e/mcp-tractability.e2e.ts`.
42
+ */
43
+ export const CURATED_MCP_TOOLS: readonly string[] = [
44
+ // ── Drive / act ──────────────────────────────────────────────────────────
45
+ "startConvergenceLoop",
46
+ "startPlanFanout",
47
+ "startEpicSet",
48
+ "startFeature",
49
+ "compileDeliveryGraph",
50
+ "previewDeliveryGraph",
51
+ "sequenceIssues",
52
+ "agentCompleteEscalation",
53
+ "completeUserTask",
54
+ "cancelInstance",
55
+ "appendBlackboard",
56
+ "readBlackboard",
57
+ // ── Read / orient ────────────────────────────────────────────────────────
58
+ "getVersion",
59
+ "getAgentInstructions",
60
+ "getAgentGuide",
61
+ "listActivePrs",
62
+ "listStagedProposals",
63
+ "listEscalations",
64
+ "getLineage",
65
+ "getPrHistory",
66
+ // ── Engine-truth reads (wedge triage) ────────────────────────────────────
67
+ "urban_debug_search_process_instances",
68
+ "urban_debug_search_element_instance_wait_states",
69
+ "urban_debug_search_incidents",
70
+ "urban_debug_search_variables",
71
+ "urban_debug_search_jobs",
72
+ "urban_debug_instance_state",
73
+ "urban_debug_open_user_tasks",
74
+ ];
75
+
76
+ /** The framework-reserved namespace for engine-debug tools (mirrors the runtime's `DEBUG_PREFIX`).
77
+ * A curated entry with this prefix is a framework tool (not an `openapi.yaml` operation), so the
78
+ * spec-level unit guard validates it by prefix rather than against the projected operation set. */
79
+ export const FRAMEWORK_TOOL_PREFIX = "urban_debug_";
80
+
81
+ /**
82
+ * Hard CEILING on the projected `tools/list` tool count. The deployed surface measures 56 (issue
83
+ * #715); this budget forbids GROWTH — a new door that pushes the count over fails CI. It is a
84
+ * regression guard, not the reduction itself: the reduction an agent actually experiences comes from
85
+ * importing {@link CURATED_MCP_TOOLS} rather than `["*"]`, and the transport-level shrink of the
86
+ * framework tool family is tracked upstream (nano-ide#488).
87
+ */
88
+ export const MCP_TOOL_COUNT_BUDGET = 60;
89
+
90
+ /**
91
+ * Hard CEILING on the serialized byte size of the full `tools/list` payload (the schema bytes a
92
+ * client must parse). The deployed surface measures ~78,962 bytes (issue #715); this ceiling forbids
93
+ * meaningful growth so a fat new schema cannot silently re-inflate the surface past the harness
94
+ * deferral point.
95
+ */
96
+ export const MCP_SURFACE_BYTES_BUDGET = 84_000;
97
+
98
+ /**
99
+ * The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
100
+ * is not "tractable". This ceiling pins the curated set at roughly half the full count so a creeping
101
+ * curation cannot quietly grow back toward `["*"]`.
102
+ */
103
+ export const CURATED_MCP_TOOLS_BUDGET = 30;
104
+
105
+ /**
106
+ * The per-call budget (ms) a heavy tool (synchronous BPMN layout — `compileDeliveryGraph` /
107
+ * `previewDeliveryGraph` / `sequenceIssues`) must complete within so a cold call does not exceed a
108
+ * typical MCP client's request timeout and `-32001` (issue #715 gap 4). Measured cold at ~0.5 s in
109
+ * the hermetic harness; the 4 s ceiling leaves generous headroom while still failing loudly if a
110
+ * heavy door regresses into a multi-second stall.
111
+ */
112
+ export const HEAVY_TOOL_BUDGET_MS = 4_000;
@@ -69,6 +69,82 @@ copilot mcp add --transport http workforce-merlin http://merlin.local:3000/app/m
69
69
  Tool calls are namespaced per server entry, so the instance you name is the instance
70
70
  you drive.
71
71
 
72
+ ### Import a curated subset, not `["*"]` (tractable surface — issue #715)
73
+
74
+ The full projected surface is **~56 tools / ~79 KB** of `tools/list` (app operations + the
75
+ framework `urban_debug_*` engine family). That is large enough that a coding-agent harness
76
+ **defers the whole set behind a tool-search gate**, and `"tools": ["*"]` imports every one of
77
+ them eagerly — so an agent may not see nwf's tools until it searches. Until the runtime shrinks
78
+ the framework tool family behind a mode (tracked upstream, nano-ide#488), the workforce-side lever
79
+ is to import a **curated allowlist** of the tools you actually drive/read with, instead of `["*"]`:
80
+
81
+ ```json
82
+ "workforce-local": {
83
+ "type": "http",
84
+ "url": "http://localhost:3000/app/mcp",
85
+ "tools": <curated list below>
86
+ }
87
+ ```
88
+
89
+ The curated list is the single source of truth in **`app/mcpToolSurface.ts`**
90
+ (`CURATED_MCP_TOOLS`) — `e2e/mcp-tractability.e2e.ts` asserts every entry projects onto the live
91
+ surface, and `npm run sync:mcp-curated:check` (also asserted by a drift test under `npm test`, which
92
+ CI runs) fails if the block below drifts from the source. Never hand-edit it; edit
93
+ `app/mcpToolSurface.ts` and run `npm run sync:mcp-curated`.
94
+
95
+ <!-- BEGIN GENERATED: curated-tools (npm run sync:mcp-curated) -->
96
+ ```json
97
+ [
98
+ "startConvergenceLoop",
99
+ "startPlanFanout",
100
+ "startEpicSet",
101
+ "startFeature",
102
+ "compileDeliveryGraph",
103
+ "previewDeliveryGraph",
104
+ "sequenceIssues",
105
+ "agentCompleteEscalation",
106
+ "completeUserTask",
107
+ "cancelInstance",
108
+ "appendBlackboard",
109
+ "readBlackboard",
110
+ "getVersion",
111
+ "getAgentInstructions",
112
+ "getAgentGuide",
113
+ "listActivePrs",
114
+ "listStagedProposals",
115
+ "listEscalations",
116
+ "getLineage",
117
+ "getPrHistory",
118
+ "urban_debug_search_process_instances",
119
+ "urban_debug_search_element_instance_wait_states",
120
+ "urban_debug_search_incidents",
121
+ "urban_debug_search_variables",
122
+ "urban_debug_search_jobs",
123
+ "urban_debug_instance_state",
124
+ "urban_debug_open_user_tasks"
125
+ ]
126
+ ```
127
+ <!-- END GENERATED: curated-tools -->
128
+
129
+ Keeping `["*"]` still works and is fine on a client that does not defer — the curated import is the
130
+ recommended default for a harness that gates on tool count. The full surface (including any
131
+ `urban_debug_*` tool not in the curated set) stays reachable; drop back to `["*"]`, or add the
132
+ specific extra tool name to the entry, whenever you need one outside the curated set.
133
+
134
+ ### Recover a lost session — re-`initialize` on `-32000` (issue #715, gap 1)
135
+
136
+ The `/app/mcp` transport is **stateful streamable-HTTP**: every `tools/call` carries an
137
+ `mcp-session-id`, and a call with a missing / stale / idle-dropped / proxy-reset / LRU-evicted
138
+ session id is refused with `-32000 "Bad Request: no valid session id, and not an initialize
139
+ request."` A client that does not re-handshake then sees the **whole surface** report "tool does not
140
+ exist" until it re-`initialize`s — a single hiccup (notably a heavy-tool timeout, gap 4 below) can
141
+ brick every tool. The **self-heal** is a fresh `initialize` handshake, which mints a new session and
142
+ restores the entire catalogue in one round trip; a well-behaved MCP client does this automatically on
143
+ a `-32000`. The stateless/resumable transport that would remove the session dependency entirely is
144
+ tracked upstream (nano-ide#488); `e2e/mcp-session-selfheal.e2e.ts` pins the workforce-visible
145
+ requirement — a killed session (stale id **and** a server-side `DELETE`) bricks a call `-32000`, and
146
+ one re-`initialize` brings the full surface back.
147
+
72
148
  ### Instance behind Basic Auth? You need *both* headers
73
149
 
74
150
  Two different layers. `x-hook-secret` is the **app's own** guard (checked by nwf in
@@ -0,0 +1,113 @@
1
+ // Session self-heal regression — the dominant #715 gap (gap 1).
2
+ //
3
+ // WHAT THIS PINS
4
+ // ==============
5
+ // The runtime-served MCP surface (`/app/mcp`, ADR 0067) is a **stateful streamable-HTTP** transport:
6
+ // every `tools/call` MUST carry a valid `mcp-session-id`, and a call with a missing / stale / evicted
7
+ // / deleted session id is refused with JSON-RPC `-32000 "Bad Request: no valid session id, and not an
8
+ // initialize request."` (mcp.ts). In the field (issue #715) a single hiccup — a heavy-tool timeout,
9
+ // an idle drop, a proxy reset, or LRU eviction (`MAX_SESSIONS`) — loses the session and then bricks
10
+ // the ENTIRE surface for a client that does not re-`initialize`: every subsequent tool reads as
11
+ // "tool does not exist". The stateless/resumable transport that would remove the session dependency
12
+ // lives in the urban runtime and is tracked upstream (nano-ide#488); until it lands, the
13
+ // **workforce-visible requirement** (this issue) is that the surface is RECOVERABLE — a client that
14
+ // re-`initialize`s after a `-32000` gets the WHOLE surface back in one round trip, not a degraded one.
15
+ //
16
+ // This is the acceptance regression: "kill the session mid-flight and assert the next call still
17
+ // works." It kills the session two faithful ways — an unknown/stale id, and a server-side `DELETE`
18
+ // (the spec session-termination verb) of a live id — asserts each bricks a call with the exact
19
+ // `-32000` signature, then asserts a single client `reinitialize()` fully restores the surface
20
+ // (a working `tools/call` AND the complete `tools/list`). If a future runtime makes the transport
21
+ // stateless/resumable (nano-ide#488), the stale-id call simply stops erroring — this test then
22
+ // tightens to that stronger contract with a one-line change, never silently passing on a regression.
23
+ //
24
+ // Run with `npm run e2e`.
25
+ import assert from "node:assert/strict";
26
+ import { randomUUID } from "node:crypto";
27
+ import { after, before, describe, test } from "node:test";
28
+ import { bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
29
+
30
+ /** The exact runtime signature of a lost/absent session (mcp.ts). A recovered surface must NOT
31
+ * answer with this after a re-`initialize`. */
32
+ const NO_SESSION_SIGNATURE = "no valid session id";
33
+
34
+ /** Any "the session is gone" refusal: the runtime's own `-32000` "no valid session id" (unknown id)
35
+ * OR the SDK transport's "Session not found" (a terminated/DELETEd id). Either proves the call was
36
+ * refused because the session no longer exists — the field failure #715 gap 1 is about. */
37
+ const SESSION_GONE = /no valid session id|session not found/i;
38
+
39
+ /** A safe, side-effect-free read used as the "does the surface answer?" probe. */
40
+ const PROBE_TOOL = "getVersion";
41
+
42
+ describe("#715 gap 1 — a lost MCP session self-heals on client re-initialize", () => {
43
+ let h: McpHarness;
44
+ before(async () => {
45
+ h = await bootMcpHarness();
46
+ });
47
+ after(async () => {
48
+ await h.stop();
49
+ });
50
+
51
+ test("baseline: a call on the live session works", async () => {
52
+ const res = await h.callTool(PROBE_TOOL);
53
+ assert(!res.isError, `baseline ${PROBE_TOOL} should succeed on a live session: ${res.text}`);
54
+ });
55
+
56
+ test("an unknown/stale session id bricks a call with -32000, and re-initialize restores the surface", async () => {
57
+ // A stale id models an evicted (LRU / idle-dropped) or proxy-reset session the client still holds.
58
+ const staleId = `stale-${randomUUID()}`;
59
+ const bricked = await h.callToolAs(staleId, PROBE_TOOL);
60
+ assert(bricked.isError, "a call carrying a stale session id must be refused, not answered");
61
+ assert(
62
+ bricked.text.includes(NO_SESSION_SIGNATURE),
63
+ `a stale-session call must fail with the "${NO_SESSION_SIGNATURE}" signature, got: ${bricked.text}`,
64
+ );
65
+
66
+ // Client self-heal: re-run the handshake. The pinned runtime is stateful, so this is how a real
67
+ // client recovers (nano-ide#488 would make it unnecessary).
68
+ const newId = await h.reinitialize();
69
+ assert(newId && newId !== staleId, "reinitialize must mint a fresh session id");
70
+
71
+ // The NEXT call works — the whole surface is back, not a degraded subset.
72
+ const healed = await h.callTool(PROBE_TOOL);
73
+ assert(!healed.isError, `after reinitialize the surface must answer again: ${healed.text}`);
74
+ assert(
75
+ !healed.text.includes(NO_SESSION_SIGNATURE),
76
+ "a healed call must not still report a missing session",
77
+ );
78
+
79
+ // And the FULL projected catalogue is restored — the field failure was "every tool vanished".
80
+ const tools = await h.listTools();
81
+ assert(tools.length > 1, `the full tools/list must be restored after self-heal, got ${tools.length}`);
82
+ assert(
83
+ tools.some((t) => t.name === PROBE_TOOL),
84
+ `the restored catalogue must still project ${PROBE_TOOL}`,
85
+ );
86
+ });
87
+
88
+ test("a server-side DELETE ends the session mid-flight; the old id then bricks and re-initialize heals it", async () => {
89
+ // Prove the harness's current session is live first.
90
+ const before = await h.callTool(PROBE_TOOL);
91
+ assert(!before.isError, `session should be live before DELETE: ${before.text}`);
92
+ const killedId = h.sessionId;
93
+
94
+ // Kill it the spec way — DELETE terminates the session server-side (mcp.ts: "POST, DELETE").
95
+ const status = await h.deleteSession(killedId);
96
+ assert(status < 500, `DELETE session should not server-error, got ${status}`);
97
+
98
+ // The now-terminated id bricks a call — the mid-flight hiccup. A terminated session surfaces the
99
+ // SDK transport's "Session not found"; an unknown id surfaces the runtime's "no valid session id"
100
+ // — both mean the session is gone and the call was refused, not answered.
101
+ const bricked = await h.callToolAs(killedId, PROBE_TOOL);
102
+ assert(bricked.isError, "a call on a DELETEd session id must be refused");
103
+ assert(
104
+ SESSION_GONE.test(bricked.text),
105
+ `a DELETEd-session call must be refused as a gone session, got: ${bricked.text}`,
106
+ );
107
+
108
+ // Client re-initialize → the very next call works again.
109
+ await h.reinitialize();
110
+ const healed = await h.callTool(PROBE_TOOL);
111
+ assert(!healed.isError, `after reinitialize the surface must answer again: ${healed.text}`);
112
+ });
113
+ });
@@ -0,0 +1,114 @@
1
+ // Tractable-surface + heavy-tool budget guards (#715 gaps 2 & 4).
2
+ //
3
+ // WHAT THIS PINS
4
+ // ==============
5
+ // Gap 2 — the projected `/app/mcp` `tools/list` surface measures 56 tools / ~79 KB against the
6
+ // deployed instance (issue #715): large enough that an agent harness DEFERS the whole set behind a
7
+ // tool-search gate, and a client `"tools": ["*"]` imports all of it eagerly. Two workforce-visible
8
+ // guards keep it tractable, both derived from the ONE source of truth (`app/mcpToolSurface.ts`):
9
+ //
10
+ // • a BUDGET on the full surface (count + serialized bytes) so it can only shrink below the pinned
11
+ // ceilings — a fat new door that re-inflates it fails the build here;
12
+ // • a CURATED subset (`CURATED_MCP_TOOLS`) a client imports instead of `["*"]` — asserted to be
13
+ // materially smaller than the full surface AND to consist only of names that actually project,
14
+ // so the recommended allowlist can never point at a dead/renamed tool.
15
+ //
16
+ // Gap 4 — a cold heavy tool (synchronous BPMN layout: `previewDeliveryGraph` / `compileDeliveryGraph`)
17
+ // must complete well within a typical MCP client's per-call timeout, so a cold `tools/call` no longer
18
+ // `-32001`s (which then trips gap 1 by poisoning the session). Asserted against `HEAVY_TOOL_BUDGET_MS`.
19
+ //
20
+ // The full object-body / schema-self-containment contract (gap 3, nano-ide#501/#503) is pinned by the
21
+ // sibling `e2e/mcp-surface.e2e.ts`; this file adds only the tractability + latency dimensions.
22
+ //
23
+ // Run with `npm run e2e`.
24
+ import assert from "node:assert/strict";
25
+ import { after, before, describe, test } from "node:test";
26
+ import {
27
+ CURATED_MCP_TOOLS,
28
+ CURATED_MCP_TOOLS_BUDGET,
29
+ HEAVY_TOOL_BUDGET_MS,
30
+ MCP_SURFACE_BYTES_BUDGET,
31
+ MCP_TOOL_COUNT_BUDGET,
32
+ } from "../app/mcpToolSurface.ts";
33
+ import { bootMcpHarness, type McpHarness, type McpTool } from "./support/mcp-harness.ts";
34
+
35
+ describe("#715 gaps 2 & 4 — tractable MCP surface + heavy-tool latency budget", () => {
36
+ let h: McpHarness;
37
+ let tools: McpTool[];
38
+ before(async () => {
39
+ h = await bootMcpHarness();
40
+ tools = await h.listTools();
41
+ });
42
+ after(async () => {
43
+ await h.stop();
44
+ });
45
+
46
+ test("the full tools/list stays within the pinned count budget", () => {
47
+ assert(
48
+ tools.length <= MCP_TOOL_COUNT_BUDGET,
49
+ `projected MCP tool count ${tools.length} exceeds the budget ${MCP_TOOL_COUNT_BUDGET} — the ` +
50
+ `surface must not grow past the harness deferral point (issue #715). Either curate a door off ` +
51
+ `the surface (x-mcp) or, if this growth is intended, raise MCP_TOOL_COUNT_BUDGET deliberately ` +
52
+ `in app/mcpToolSurface.ts. Tools: ${tools.map((t) => t.name).sort().join(", ")}`,
53
+ );
54
+ });
55
+
56
+ test("the full tools/list stays within the pinned byte budget", () => {
57
+ const bytes = Buffer.byteLength(JSON.stringify(tools), "utf8");
58
+ assert(
59
+ bytes <= MCP_SURFACE_BYTES_BUDGET,
60
+ `serialized tools/list is ${bytes} bytes, over the budget ${MCP_SURFACE_BYTES_BUDGET} — a fat ` +
61
+ `new schema is re-inflating the surface (issue #715). Trim the schema, curate the door off, or ` +
62
+ `raise MCP_SURFACE_BYTES_BUDGET deliberately in app/mcpToolSurface.ts.`,
63
+ );
64
+ });
65
+
66
+ test("every curated tool actually projects onto the live surface (no dead allowlist entries)", () => {
67
+ const live = new Set(tools.map((t) => t.name));
68
+ const dead = CURATED_MCP_TOOLS.filter((name) => !live.has(name));
69
+ assert.deepEqual(
70
+ dead,
71
+ [],
72
+ `CURATED_MCP_TOOLS names ${JSON.stringify(dead)} do not project onto the live /app/mcp surface — ` +
73
+ `the recommended allowlist has drifted from the real tool set. Fix app/mcpToolSurface.ts.`,
74
+ );
75
+ });
76
+
77
+ test("the curated subset is materially smaller than the full surface (tractable import)", () => {
78
+ assert(
79
+ CURATED_MCP_TOOLS.length <= CURATED_MCP_TOOLS_BUDGET,
80
+ `the curated subset (${CURATED_MCP_TOOLS.length}) exceeds its budget ${CURATED_MCP_TOOLS_BUDGET} — ` +
81
+ `it is creeping back toward "*". Keep it to the tools an agent actually drives/reads.`,
82
+ );
83
+ assert(
84
+ CURATED_MCP_TOOLS.length < tools.length,
85
+ `the curated subset (${CURATED_MCP_TOOLS.length}) must be smaller than the full surface ` +
86
+ `(${tools.length}); otherwise importing it buys nothing over "*".`,
87
+ );
88
+ });
89
+
90
+ test("curated entries carry no duplicates", () => {
91
+ const seen = new Set<string>();
92
+ const dupes: string[] = [];
93
+ for (const name of CURATED_MCP_TOOLS) {
94
+ if (seen.has(name)) dupes.push(name);
95
+ seen.add(name);
96
+ }
97
+ assert.deepEqual(dupes, [], `CURATED_MCP_TOOLS has duplicate entries: ${JSON.stringify(dupes)}`);
98
+ });
99
+
100
+ test("a cold heavy tool (previewDeliveryGraph) completes within the client budget — no -32001", async () => {
101
+ // A minimal valid graph → real synchronous BPMN layout, the exact heavy path issue #715 saw
102
+ // time out cold. Pure door (nothing staged), so this is safe and repeatable.
103
+ const graphJson = JSON.stringify({ nodes: [{ id: "h", kind: "human" }] });
104
+ const t0 = Date.now();
105
+ const res = await h.callTool("previewDeliveryGraph", { body: { graphJson } });
106
+ const elapsed = Date.now() - t0;
107
+ assert(!res.isError, `previewDeliveryGraph should succeed on a valid graph: ${res.text}`);
108
+ assert(
109
+ elapsed <= HEAVY_TOOL_BUDGET_MS,
110
+ `cold previewDeliveryGraph took ${elapsed}ms, over the client budget ${HEAVY_TOOL_BUDGET_MS}ms — ` +
111
+ `a heavy synchronous door this slow risks the client -32001 that poisons the session (issue #715).`,
112
+ );
113
+ });
114
+ });