diff --git a/CHANGELOG.md b/CHANGELOG.md index 478b3150..827ea291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.241.1 + +Coordination public-address signals now end when their listener closes or setup fails, as well as on manager cancellation. Hosted ingress adapters can retire a route before its local port is reused, without changing authentication, execution policy or agent capabilities. + ## 0.241.0 **`exportEvalRuns` no longer invents an accepted count.** When Tangle Intelligence answered with a non-JSON body (a proxy error page, an empty 5xx), the exporter left the parsed acknowledgement empty and reported `accepted: events.length` on any 2xx, so a consumer asserting that its provenance landed could pass on an acknowledgement that never existed. The acknowledgement is now validated against the batch that was sent: `accepted` must be a bounded integer, every rejection must carry a unique in-range index and a nonempty reason, and `accepted + rejected.length` must account for every submitted event. An unreadable, malformed or incomplete acknowledgement throws with the HTTP status; a failed HTTP response that claims accepted events throws; partial acceptance resolves with `ok: false` and the validated rejections. A whitespace-only API key is refused like a missing one. blueprint-agent deletes its duplicate wire types and ingest client and imports this exporter. diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 7228833c..dd7525b1 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.241.0` and `@tangle-network/agent-eval@0.182.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.241.1` and `@tangle-network/agent-eval@0.182.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 71a8c9d4..e3e8b72d 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -13617,7 +13617,7 @@ Caller-owned secret keys. Keep prior keys to verify unexpired credentials after > `readonly` **signal**: `AbortSignal` -Manager cancellation and deadline; pass this to asynchronous endpoint provisioning. +Listener lifetime: aborts on close, failed setup, manager cancellation, or deadline. *** diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 5e84702f..9ab256b3 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -4,7 +4,7 @@ Generated signatures and the complete export list live in docs/api/. Run pnpm docs:freshness after editing this file. --> -> **Version 0.241.0.** +> **Version 0.241.1.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.182.0 <0.183.0`. > `sandbox` must satisfy `>=0.36.4 <0.42.0`. diff --git a/package.json b/package.json index 1ddc394e..63267923 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.241.0", + "version": "0.241.1", "description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { diff --git a/src/runtime/supervise/coordination-mcp.ts b/src/runtime/supervise/coordination-mcp.ts index c0b5d073..50e085c7 100644 --- a/src/runtime/supervise/coordination-mcp.ts +++ b/src/runtime/supervise/coordination-mcp.ts @@ -121,7 +121,7 @@ export interface CoordinationPublicAddress { readonly port: number readonly runId: string readonly actorId: string - /** Manager cancellation and deadline; pass this to asynchronous endpoint provisioning. */ + /** Listener lifetime: aborts on close, failed setup, manager cancellation, or deadline. */ readonly signal: AbortSignal } @@ -543,6 +543,16 @@ export async function serveCoordinationMcp( }) }) + const addressLifetime = new AbortController() + const abortAddress = () => addressLifetime.abort(opts.scope.signal.reason) + opts.scope.signal.addEventListener('abort', abortAddress, { once: true }) + if (opts.scope.signal.aborted) abortAddress() + const releaseAddress = () => { + opts.scope.signal.removeEventListener('abort', abortAddress) + if (!addressLifetime.signal.aborted) + addressLifetime.abort(new Error('coordination listener closed')) + } + const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host const localUrl = `http://${urlHost}:${port}/mcp` let url: string @@ -551,7 +561,7 @@ export async function serveCoordinationMcp( const configured = typeof resolver === 'function' ? await runAbortable( - async () => resolver({ host, port, ...identity, signal: opts.scope.signal }), + async () => resolver({ host, port, ...identity, signal: addressLifetime.signal }), opts.scope.signal, 'coordination public address resolution aborted', ) @@ -592,6 +602,7 @@ export async function serveCoordinationMcp( }) } } catch (error) { + releaseAddress() closed = true await new Promise((resolve) => { server.close(() => resolve()) @@ -623,6 +634,7 @@ export async function serveCoordinationMcp( mailHistory: () => mailbox?.history() ?? [], stopMailThread: (threadId) => mailbox?.stopThread(threadId) ?? false, close: async () => { + releaseAddress() closed = true await new Promise((resolve) => { server.close(() => resolve()) diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index 903126a2..f03e2455 100644 --- a/src/testing/fixtures/agent-improvement-proposal.json +++ b/src/testing/fixtures/agent-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt"], - "digest": "sha256:a330797b3a4808392fb26f0f70e74efe098de08e9e454b3eee841dabe9b88edd", + "digest": "sha256:b5fc58845c940d8f7c61bc9cf120b01b76b9d085a5490a1a978844a4134c0616", "evaluation": { "decision": { "contributingChecks": [ @@ -4882,7 +4882,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.241.0" + "runtimeVersion": "0.241.1" }, "objectives": [ { @@ -4993,8 +4993,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:4597b1a8fcf98875b475a08819c6b1b9bbebf2576085a0b95365b0a236954dd7", - "runId": "agent-runtime-0.241.0-proposal-fixture", + "recordDigest": "sha256:a7cce9d8e6b336c10989e74a75d160715b3a9eb1a2ff0ba0b2e1584f54dc62aa", + "runId": "agent-runtime-0.241.1-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -5021,5 +5021,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.241.0-proposal-fixture" + "runId": "agent-runtime-0.241.1-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index 49093352..c1ce2fa0 100644 --- a/src/testing/fixtures/agent-profile-improvement-proposal.json +++ b/src/testing/fixtures/agent-profile-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt", "skills"], - "digest": "sha256:4e8ac1bbaaeb4505c19b4dcb1ecefacc7bb2aef02e1df58308aad397c2af0460", + "digest": "sha256:23b992c8fc4146b76e2704f3ba38dc7b13408150439a112b2be9e73fc591aca2", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.241.0" + "runtimeVersion": "0.241.1" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:bb2732765df51993e084595a1504ba25a004f86b259b9e3ad4294d3bfaa3f9ec", + "recordDigest": "sha256:2455e1f27e26f1767fd3ad8c78fa00ab7a3928f8268d4b4b3d14dcef75a03270", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" } diff --git a/tests/kernel/coordination-mcp.test.ts b/tests/kernel/coordination-mcp.test.ts index 80eff84a..ed87f0b6 100644 --- a/tests/kernel/coordination-mcp.test.ts +++ b/tests/kernel/coordination-mcp.test.ts @@ -242,7 +242,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP // to fire before listen; a bind moved after it would leave the first caller with no verbs. let boundNames: ReadonlyArray | undefined let boundBeforeFirstCall: boolean | undefined - const scope = {} as Scope + const scope = { signal: new AbortController().signal } as Scope const mcp = await serveCoordinationMcp({ scope, blobs: new InMemoryResultBlobStore(), @@ -279,7 +279,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP it('serves product-owned node tools beside coordination tools over the same HTTP MCP', async () => { const calls: unknown[] = [] - const scope = {} as Scope + const scope = { signal: new AbortController().signal } as Scope const mcp = await serveCoordinationMcp({ scope, blobs: new InMemoryResultBlobStore(), @@ -325,7 +325,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP it('refuses a product tool that shadows spawn_worker before opening a listener', async () => { await expect( serveCoordinationMcp({ - scope: {} as Scope, + scope: { signal: new AbortController().signal } as Scope, blobs: new InMemoryResultBlobStore(), makeWorkerAgent: () => deliveringLeaf('unused', {}), perWorker: { maxIterations: 1, maxTokens: 1 }, @@ -345,7 +345,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP it('refuses duplicate explicit tool grants before opening a listener', async () => { await expect( serveCoordinationMcp({ - scope: {} as Scope, + scope: { signal: new AbortController().signal } as Scope, blobs: new InMemoryResultBlobStore(), makeWorkerAgent: () => deliveringLeaf('unused', {}), perWorker: { maxIterations: 1, maxTokens: 1 }, @@ -1312,7 +1312,7 @@ async function withMethodTool( let calls = 0 const runs: Array>> = [] const mcp = await serveCoordinationMcp({ - scope: {} as Scope, + scope: { signal: new AbortController().signal } as Scope, blobs: new InMemoryResultBlobStore(), makeWorkerAgent: () => deliveringLeaf('unused', {}), perWorker: { maxIterations: 1, maxTokens: 1 }, @@ -1644,3 +1644,88 @@ describe('coordination lifetime validation', () => { }, ) }) + +describe('public coordination address lifetime', () => { + it('releases its registered address on normal close while the manager remains live', async () => { + const proxy = await publicProxy() + let addressSignal: AbortSignal | undefined + const blobs = new InMemoryResultBlobStore() + const root: Agent = { + name: 'address-owner', + async act(_task, scope) { + const mcp = await serveCoordinationMcp({ + scope, + blobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker: { maxIterations: 1, maxTokens: 1000 }, + authentication: true, + toolNames: [], + publicUrl: (address) => { + addressSignal = address.signal + proxy.forwardTo(address.port) + return `${proxy.url}/mcp` + }, + }) + expect(addressSignal?.aborted).toBe(false) + await mcp.close() + expect(scope.signal.aborted).toBe(false) + expect(addressSignal?.aborted).toBe(true) + await mcp.close() + return { closed: true } + }, + } + const result = await createSupervisor().run( + root, + {}, + { + runId: 'address-close', + budget: { maxIterations: 10, maxTokens: 10_000 }, + journal: new InMemorySpawnJournal(), + blobs, + executors: createExecutorRegistry(), + }, + ) + if (!addressSignal) throw new Error(JSON.stringify(result)) + expect(addressSignal?.aborted).toBe(true) + }) + + it('releases a registered address when public endpoint setup fails', async () => { + const blobs = new InMemoryResultBlobStore() + let addressSignal: AbortSignal | undefined + const root: Agent = { + name: 'invalid-address', + async act(_task, scope) { + await expect( + serveCoordinationMcp({ + scope, + blobs, + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker: { maxIterations: 1, maxTokens: 1000 }, + authentication: true, + toolNames: [], + publicUrl: (address) => { + addressSignal = address.signal + return 'http://remote.invalid/mcp' + }, + }), + ).rejects.toThrow('must use HTTPS') + expect(scope.signal.aborted).toBe(false) + expect(addressSignal?.aborted).toBe(true) + return { refused: true } + }, + } + const result = await createSupervisor().run( + root, + {}, + { + runId: 'address-setup', + budget: { maxIterations: 10, maxTokens: 10_000 }, + journal: new InMemorySpawnJournal(), + blobs, + executors: createExecutorRegistry(), + }, + ) + if (!addressSignal) throw new Error(JSON.stringify(result)) + expect(addressSignal?.aborted).toBe(true) + }) +})