diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e980a1e..8ea21f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.193.0 + +### Omitted code-mode deadlines no longer stop work + +`CodeModeOptions.timeoutMs` now accepts `null`, and an omitted value no longer creates a +60-second deadline. +Code-mode programs continue until the manager cancels them or a caller supplies a positive +`timeoutMs`. +Consumers that require a wall-clock limit must now declare it explicitly. + +### Live children can settle without a clock + +`SuperviseOptions.childSettleGraceMs` and `SupervisorOpts.childSettleGraceMs` now accept `null`. +That value keeps the join open until every child settles or the caller cancels the run. +An explicit run deadline still bounds the join. +Omitted and zero values retain immediate teardown. + ## 0.192.0 ### Provider date snapshots retain model identity diff --git a/api-surface.json b/api-surface.json index 13926a6b..02be5236 100644 --- a/api-surface.json +++ b/api-surface.json @@ -1157,7 +1157,7 @@ "CliSeam": "type 69bfe2571bab", "CliWorktreeBridgeSeam": "type 81fa5d344141", "CliWorktreeSeam": "type d5be6307c356", - "CodeModeOptions": "type 7968eb5eb81b", + "CodeModeOptions": "type 846ed388a825", "CodeModeRunner": "type 644646a508d0", "CodexForkBoundary": "type 5335e473148a", "CodexRolloutIdentity": "type 13675db20eb0", @@ -1573,7 +1573,7 @@ "StructuralRolloutPolicy": "type d372912ef050", "StructuralRolloutResult": "type 9248b72cae04", "SuperviseDispatchOptions": "type 55e71c9c1e6b", - "SuperviseOptions": "type 0792601cf1d0", + "SuperviseOptions": "type 51029d9e2b96", "SuperviseOptionsForDispatch": "type 7d89526c1040", "SuperviseRegistry": "type 4fd60c297f74", "SuperviseRegistryTable": "type cc1468cd50c1", @@ -1586,7 +1586,7 @@ "SupervisorFinalizer": "type f8628e65536f", "SupervisorNodeContext": "type b8e545bbb355", "SupervisorNodeContextSeed": "type da1e69b5be66", - "SupervisorOpts": "type 97f0a700e6a3", + "SupervisorOpts": "type 11a618807bda", "SupervisorProfile": "type 217ef5d5fe3f", "SupervisorSpanAttributes": "type 1a1e9cd7e217", "SupervisorSpanOptions": "type 1f63a31dfa75", diff --git a/docs/api/durable.md b/docs/api/durable.md index b9962bca..7359f6c3 100644 --- a/docs/api/durable.md +++ b/docs/api/durable.md @@ -1507,12 +1507,11 @@ Compose the re-entry instruction for an unmet contract, or return `'stop'` to en ##### childSettleGraceMs? -> `readonly` `optional` **childSettleGraceMs?**: `number` +> `readonly` `optional` **childSettleGraceMs?**: `number` \| `null` -How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier -cascades the abort into them. A root that died did not make its children unhealthy: a child -mid-unit holds work already paid for, and an immediate cascade discards everything it has not -yet written. Bounded by the run's own deadline. Omit/`0` = immediate teardown. +How long live children may keep running after the root driver returns or fails, before the join +barrier cascades the abort into them. `null` waits until children settle or the caller cancels. +An explicit run deadline always wins. Omit/`0` = immediate teardown. ###### Inherited from diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 017f7158..41f4f548 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.192.5` and `@tangle-network/agent-eval@0.173.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.193.0` and `@tangle-network/agent-eval@0.173.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 1a49055e..ff82ca51 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -11784,14 +11784,14 @@ Where model-written code runs. THE isolation boundary — see the module doc: th `Readonly`\<`Record`\<`string`, (`args`) => `Promise`\<`unknown`\>\>\> -The granted operations, already deadline-gated and result-detached by the caller. The +The granted operations, already cancellation-gated and result-detached by the caller. The runner exposes these to the program as `api.` and adds nothing else reachable. ###### signal `AbortSignal` -Aborts when the whole-program deadline passes or the manager scope cancels. +Aborts when the manager cancels or a caller-authored deadline passes. ###### Returns @@ -11805,11 +11805,10 @@ Aborts when the whole-program deadline passes or the manager scope cancels. ##### timeoutMs? -> `readonly` `optional` **timeoutMs?**: `number` +> `readonly` `optional` **timeoutMs?**: `number` \| `null` -Whole-program deadline per `execute` call. Default 60_000. After it passes, the running - program's next `api` call fails closed, so a runaway loop cannot keep spawning workers the - model can no longer see. +Optional caller-authored deadline for one `execute` call. Omit it to run until the manager + cancels. A declared deadline aborts the runner and refuses later `api` calls. *** @@ -13491,12 +13490,11 @@ Compose the re-entry instruction for an unmet contract, or return `'stop'` to en ##### childSettleGraceMs? -> `readonly` `optional` **childSettleGraceMs?**: `number` +> `readonly` `optional` **childSettleGraceMs?**: `number` \| `null` -How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier -cascades the abort into them. A root that died did not make its children unhealthy: a child -mid-unit holds work already paid for, and an immediate cascade discards everything it has not -yet written. Bounded by the run's own deadline. Omit/`0` = immediate teardown. +How long live children may keep running after the root driver returns or fails, before the join +barrier cascades the abort into them. `null` waits until children settle or the caller cancels. +An explicit run deadline always wins. Omit/`0` = immediate teardown. ###### Inherited from @@ -17788,12 +17786,11 @@ Compose the re-entry instruction for an unmet contract, or return `'stop'` to en ##### childSettleGraceMs? -> `readonly` `optional` **childSettleGraceMs?**: `number` +> `readonly` `optional` **childSettleGraceMs?**: `number` \| `null` -How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier -cascades the abort into them. A root that died did not make its children unhealthy: a child -mid-unit holds work already paid for, and an immediate cascade discards everything it has not -yet written. Bounded by the run's own deadline. Omit/`0` = immediate teardown. +How long live children may keep running after the root driver returns or fails, before the join +barrier cascades the abort into them. `null` waits until children settle or the caller cancels. +An explicit run deadline always wins. Omit/`0` = immediate teardown. ##### resolveDriveHarness? @@ -20286,13 +20283,13 @@ trips the supervisor to `no-winner` rather than restarting forever. ##### childSettleGraceMs? -> `readonly` `optional` **childSettleGraceMs?**: `number` +> `readonly` `optional` **childSettleGraceMs?**: `number` \| `null` -How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier -cascades the abort into them (#741). A root that dies did not make its children unhealthy: a -child mid-unit holds work already paid for, and killing it instantly discards everything it has -not yet written. The window applies ONLY to a driver failure on an un-cancelled run, and never -extends past the run's own deadline. Omit/`0` = the historical immediate teardown. +How long live children may keep running after the root driver returns or fails, before the join +barrier cascades the abort into them (#741). A child mid-unit holds work already paid for, and +killing it instantly discards everything it has not yet written. `null` waits until children +settle or the caller cancels. An explicit run deadline always wins. Omit/`0` = immediate +teardown. ##### resume? diff --git a/docs/api/testing.md b/docs/api/testing.md index 2852bbe0..a9abade9 100644 --- a/docs/api/testing.md +++ b/docs/api/testing.md @@ -886,12 +886,11 @@ Compose the re-entry instruction for an unmet contract, or return `'stop'` to en ##### childSettleGraceMs? -> `readonly` `optional` **childSettleGraceMs?**: `number` +> `readonly` `optional` **childSettleGraceMs?**: `number` \| `null` -How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier -cascades the abort into them. A root that died did not make its children unhealthy: a child -mid-unit holds work already paid for, and an immediate cascade discards everything it has not -yet written. Bounded by the run's own deadline. Omit/`0` = immediate teardown. +How long live children may keep running after the root driver returns or fails, before the join +barrier cascades the abort into them. `null` waits until children settle or the caller cancels. +An explicit run deadline always wins. Omit/`0` = immediate teardown. ###### Inherited from @@ -1738,12 +1737,11 @@ Compose the re-entry instruction for an unmet contract, or return `'stop'` to en ##### childSettleGraceMs? -> `readonly` `optional` **childSettleGraceMs?**: `number` +> `readonly` `optional` **childSettleGraceMs?**: `number` \| `null` -How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier -cascades the abort into them. A root that died did not make its children unhealthy: a child -mid-unit holds work already paid for, and an immediate cascade discards everything it has not -yet written. Bounded by the run's own deadline. Omit/`0` = immediate teardown. +How long live children may keep running after the root driver returns or fails, before the join +barrier cascades the abort into them. `null` waits until children settle or the caller cancels. +An explicit run deadline always wins. Omit/`0` = immediate teardown. ###### Inherited from diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 2e33b631..dc802ea8 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.192.5.** +> **Version 0.193.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.173.0 <0.174.0`. > `sandbox` must satisfy `>=0.36.4 <0.38.0`. diff --git a/package.json b/package.json index 18bf2371..603b2d9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.192.5", + "version": "0.193.0", "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/code-mode.ts b/src/runtime/supervise/code-mode.ts index ae78fbe7..0122e59b 100644 --- a/src/runtime/supervise/code-mode.ts +++ b/src/runtime/supervise/code-mode.ts @@ -146,10 +146,10 @@ export function renderCodeModeApi( export interface CodeModeRunner { run(args: { readonly code: string - /** The granted operations, already deadline-gated and result-detached by the caller. The + /** The granted operations, already cancellation-gated and result-detached by the caller. The * runner exposes these to the program as `api.` and adds nothing else reachable. */ readonly bindings: Readonly Promise>> - /** Aborts when the whole-program deadline passes or the manager scope cancels. */ + /** Aborts when the manager cancels or a caller-authored deadline passes. */ readonly signal: AbortSignal }): Promise<{ readonly result: unknown; readonly logs: ReadonlyArray }> } @@ -240,10 +240,9 @@ function detach(value: unknown): unknown { // ── The two tools ─────────────────────────────────────────────────────────────── export interface CodeModeOptions { - /** Whole-program deadline per `execute` call. Default 60_000. After it passes, the running - * program's next `api` call fails closed, so a runaway loop cannot keep spawning workers the - * model can no longer see. */ - readonly timeoutMs?: number + /** Optional caller-authored deadline for one `execute` call. Omit it to run until the manager + * cancels. A declared deadline aborts the runner and refuses later `api` calls. */ + readonly timeoutMs?: number | null } /** @@ -266,7 +265,12 @@ export function codeModeSupervisorTools( 'codeModeSupervisorTools: a CodeModeRunner is required (no default) — pass unsafeInProcessRunner() for trusted output, or a jailed runner for untrusted models', ) } - const timeoutMs = options.timeoutMs ?? 60_000 + const timeoutMs = options.timeoutMs ?? null + if (timeoutMs !== null && (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)) { + throw new ValidationError( + 'codeModeSupervisorTools: timeoutMs must be a positive safe integer or null', + ) + } const faces = (context: SupervisorToolInvocationContext): ReadonlyArray => context @@ -315,25 +319,30 @@ export function codeModeSupervisorTools( // runner is what isolates. See the module doc. assertAuthoredCode(code, { context: `code mode (${context.nodeId})` }) - // The whole-program deadline: a local controller linked to the manager scope signal and a - // timer. After it fires, every binding fails closed, so no api call lands post-deadline; - // the listener is removed in finally so it never outlives this execute call. - const deadline = new AbortController() - const onScopeAbort = () => deadline.abort(abortReason(context.signal)) - const timer = setTimeout( - () => - deadline.abort( - new ValidationError(`code mode: program timed out after ${timeoutMs}ms`), - ), - timeoutMs, - ) - if (context.signal.aborted) deadline.abort(abortReason(context.signal)) + // Link execution to the manager's cancellation signal. Add a timer only when the caller + // declared one. After either signal fires, every binding fails closed. + const execution = new AbortController() + let timedOut = false + const onScopeAbort = () => execution.abort(abortReason(context.signal)) + const timer = + timeoutMs === null + ? null + : setTimeout(() => { + if (!execution.signal.aborted) { + timedOut = true + execution.abort( + new ValidationError(`code mode: program timed out after ${timeoutMs}ms`), + ) + } + }, timeoutMs) + if (context.signal.aborted) execution.abort(abortReason(context.signal)) else context.signal.addEventListener('abort', onScopeAbort, { once: true }) const bindings: Record Promise> = {} for (const [wire, member] of Object.entries(CODE_CALLABLE_VERBS)) { bindings[wire] = async (args: unknown) => { - if (deadline.signal.aborted) { + if (execution.signal.aborted) { + if (!timedOut) throw abortReason(execution.signal) throw new ValidationError( `code mode: the execute deadline passed; api.${wire} is refused so no work outlives the call`, ) @@ -342,9 +351,9 @@ export function codeModeSupervisorTools( } } try { - return await runner.run({ code, bindings, signal: deadline.signal }) + return await runner.run({ code, bindings, signal: execution.signal }) } finally { - clearTimeout(timer) + if (timer !== null) clearTimeout(timer) context.signal.removeEventListener('abort', onScopeAbort) } }, diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index 587f415f..205ca13d 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -1290,12 +1290,11 @@ export interface SuperviseOptions { * owes and reports how many workers passed the check. */ readonly onUnmetContract?: OnUnmetContract /** - * How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier - * cascades the abort into them. A root that died did not make its children unhealthy: a child - * mid-unit holds work already paid for, and an immediate cascade discards everything it has not - * yet written. Bounded by the run's own deadline. Omit/`0` = immediate teardown. + * How long live children may keep running after the root driver returns or fails, before the join + * barrier cascades the abort into them. `null` waits until children settle or the caller cancels. + * An explicit run deadline always wins. Omit/`0` = immediate teardown. */ - readonly childSettleGraceMs?: number + readonly childSettleGraceMs?: number | null /** Resolve one custom external-harness session per trusted manager identity. Use this instead of * `driveHarness` when recursive managers must be independently steerable. */ readonly resolveDriveHarness?: ResolveDriveHarness diff --git a/src/runtime/supervise/supervisor.ts b/src/runtime/supervise/supervisor.ts index a6cfc4bc..db999a84 100644 --- a/src/runtime/supervise/supervisor.ts +++ b/src/runtime/supervise/supervisor.ts @@ -1062,7 +1062,7 @@ function wrapJournalForBreaker(journal: SpawnJournal, breaker: IntensityBreaker) async function drainLiveChildren( scope: Scope, controller: AbortController, - settleGraceMs = 0, + settleGraceMs: number | null = 0, ): Promise> { // Armed wait-states count here even though they are deliberately excluded from `inFlight`: a // wait holds no executor, but it DOES hold a live timer, so a run that returns without @@ -1075,13 +1075,13 @@ async function drainLiveChildren( // children finish in the meantime. Exactly ONE cursor reader either way: the grace never races // `next()`, it only decides when the abort lands. let graceTimer: ReturnType | undefined - if (settleGraceMs > 0 && !controller.signal.aborted) { + if (settleGraceMs !== null && settleGraceMs > 0 && !controller.signal.aborted) { graceTimer = setTimeout( () => controller.abort('root driver failed; child settle grace expired'), settleGraceMs, ) graceTimer.unref?.() - } else if (!controller.signal.aborted) { + } else if (settleGraceMs !== null && !controller.signal.aborted) { // Same event as the grace-timer branch above, so it carries the same named reason: one // path stating why and the other going silent is what put identical deaths in two // different diagnostic buckets. @@ -1127,13 +1127,15 @@ function describeUnconfirmed(scope: Scope): string { }` } -/** The settle window a failed driver's children actually get: the caller's grace, never past the - * run's own deadline. `0` (the default) keeps the historical immediate cascade. */ +/** The settle window a driver's children actually get: the caller's grace, never past an explicit + * run deadline. `null` means no timer. `0` (the default) cascades immediately. */ function boundedSettleGrace( - graceMs: number | undefined, + graceMs: number | null | undefined, deadlineAtMs: number, now: () => number, -): number { +): number | null { + if (graceMs === null && deadlineAtMs <= 0) return null + if (graceMs === null) return Math.max(0, deadlineAtMs - now()) if (graceMs === undefined || graceMs <= 0) return 0 if (deadlineAtMs <= 0) return graceMs return Math.max(0, Math.min(graceMs, deadlineAtMs - now())) diff --git a/src/runtime/supervise/types.ts b/src/runtime/supervise/types.ts index b5948e9d..6335c928 100644 --- a/src/runtime/supervise/types.ts +++ b/src/runtime/supervise/types.ts @@ -1463,13 +1463,13 @@ export interface SupervisorOpts { readonly maxRestarts?: number readonly withinMs?: number /** - * How long live children may keep running after the ROOT DRIVER FAILED, before the join barrier - * cascades the abort into them (#741). A root that dies did not make its children unhealthy: a - * child mid-unit holds work already paid for, and killing it instantly discards everything it has - * not yet written. The window applies ONLY to a driver failure on an un-cancelled run, and never - * extends past the run's own deadline. Omit/`0` = the historical immediate teardown. + * How long live children may keep running after the root driver returns or fails, before the join + * barrier cascades the abort into them (#741). A child mid-unit holds work already paid for, and + * killing it instantly discards everything it has not yet written. `null` waits until children + * settle or the caller cancels. An explicit run deadline always wins. Omit/`0` = immediate + * teardown. */ - readonly childSettleGraceMs?: number + readonly childSettleGraceMs?: number | null /** * Opt into RESUME-FIRST: read any prior journal tree for this `runId` BEFORE beginning a fresh * one, and when a non-empty tree exists rehydrate its committed work onto `Scope.resume` diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index 006a2e93..617262f4 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:c14a3055844811a9b14b144a6a66f50bb2182ba13a9d0f47048d25546c86b4c5", + "digest": "sha256:433f998c383e7b0a04044a656edda018a995622d84e4bbfc4e408d7117e60330", "evaluation": { "decision": { "contributingChecks": [ @@ -4882,7 +4882,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.192.5" + "runtimeVersion": "0.193.0" }, "objectives": [ { @@ -4993,8 +4993,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:b4781b956cb5ced1be63f77f9abcbe411f0ca4e5c0e633279e4244932f56b00c", - "runId": "agent-runtime-0.192.5-proposal-fixture", + "recordDigest": "sha256:a71d4d9d25b9621bec6ef0179e96046f0b0eed2f2715a27c3451fdd2364cec87", + "runId": "agent-runtime-0.193.0-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.192.5-proposal-fixture" + "runId": "agent-runtime-0.193.0-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index 46eb97cd..834f40b7 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:6ee3a5c0f8ae771061feaacf48478aaebdde0cb1c32b8c451c35145514ef9a5c", + "digest": "sha256:1f35e72238574b46a51a31d680bbb75017df5eb568cdff02e0ccf72f61c894de", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.192.5" + "runtimeVersion": "0.193.0" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:02560716e866180527775a59790db89dcd1b50b3d4ce4914683d85433dbcd664", + "recordDigest": "sha256:2dadb5ec86bc3360b6b8800b0b0bcf04892c70ec973f548e6f45d04571a56991", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" } diff --git a/tests/kernel/code-mode.test.ts b/tests/kernel/code-mode.test.ts index a3679ddb..6990fbe1 100644 --- a/tests/kernel/code-mode.test.ts +++ b/tests/kernel/code-mode.test.ts @@ -7,7 +7,7 @@ */ import type { AgentProfile } from '@tangle-network/agent-interface' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { InMemorySpawnJournal } from '../../src/durable/spawn-journal' import { codeModeSupervisorTools, @@ -166,7 +166,7 @@ describe('the sandbox — bindings are the only capability', () => { }) }) -describe('the execute deadline gates api calls — no work outlives the call (surgical)', () => { +describe('caller-authored execution deadlines and manager cancellation', () => { /** A minimal invocation context: a call-counting `verbs`, a live signal, a coordinationTools * face. Enough to drive the execute handler without a full supervise run. */ function fakeContext(signal: AbortSignal, onSpawn: () => void) { @@ -205,6 +205,39 @@ describe('the execute deadline gates api calls — no work outlives the call (su >[1] } + it('an omitted deadline keeps running until the manager cancels', async () => { + vi.useFakeTimers() + try { + let executionSignal: AbortSignal | undefined + const runner = { + run: ({ signal }: { signal: AbortSignal }) => { + executionSignal = signal + return new Promise((_resolve, reject) => { + if (signal.aborted) reject(signal.reason) + else signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }, + } + const tools = codeModeSupervisorTools(runner)([] as never) + const execute = tools.find((tool) => tool.name === 'execute') + if (!execute) throw new Error('no execute tool') + const manager = new AbortController() + const pending = execute.handler( + { code: 'await new Promise(() => {})' }, + fakeContext(manager.signal, () => {}), + ) + const observed = pending.catch((error: unknown) => error) + + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000) + expect(executionSignal?.aborted).toBe(false) + + manager.abort(new Error('manager cancelled')) + await expect(observed).resolves.toMatchObject({ message: 'manager cancelled' }) + } finally { + vi.useRealTimers() + } + }) + it('a program that spawns in a loop is halted by the deadline; the count is bounded and it rejects', async () => { const tools = codeModeSupervisorTools(unsafeInProcessRunner(), { timeoutMs: 30 })([] as never) const execute = tools.find((tool) => tool.name === 'execute') @@ -245,7 +278,7 @@ describe('the execute deadline gates api calls — no work outlives the call (su spawns += 1 }), ), - ).rejects.toThrow(/deadline passed|scope cancelled/) + ).rejects.toThrow(/scope cancelled/) expect(spawns).toBe(0) }) }) diff --git a/tests/kernel/supervise.test.ts b/tests/kernel/supervise.test.ts index 5c51ac2b..780efc0e 100644 --- a/tests/kernel/supervise.test.ts +++ b/tests/kernel/supervise.test.ts @@ -1,5 +1,5 @@ import type { AgentProfile } from '@tangle-network/agent-interface' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { InMemoryResultBlobStore, InMemorySpawnJournal, @@ -1964,6 +1964,92 @@ describe('supervisor: a driver that died did not make its children unhealthy (#7 ).toBe(true) }) + it('lets live children continue without an implicit clock when the caller declares no grace limit', async () => { + vi.useFakeTimers() + try { + const gate = deferred() + const supervisor = createSupervisor() + let settled = false + const run = supervisor + .run( + { + name: 'returning-driver', + async act(task, scope: Scope): Promise { + scope.spawn( + leafAgent('worker', { out: 'w', events: tokensOnly(7, 3, 1), block: gate.promise }), + task, + { budget: { maxIterations: 1, maxTokens: 1000 }, label: 'worker' }, + ) + return 'root-returned-early' + }, + }, + 'task', + supervisorOpts({ runId: 'settle-without-clock', childSettleGraceMs: null }), + ) + .finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000) + expect(settled).toBe(false) + + gate.resolve() + const result = await run + expect(result.spentTotal.tokens.input).toBe(7) + expect(result.spentTotal.tokens.output).toBe(3) + expect( + result.tree.nodes.some((node) => node.label === 'worker' && node.status === 'done'), + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('keeps an explicit run deadline authoritative over unlimited child settlement', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const neverSettles = deferred() + const supervisor = createSupervisor() + const run = supervisor.run( + { + name: 'returning-driver', + async act(task, scope: Scope): Promise { + scope.spawn( + leafAgent('worker', { + out: 'w', + events: tokensOnly(7, 3, 1), + block: neverSettles.promise, + }), + task, + { budget: { maxIterations: 1, maxTokens: 1000 }, label: 'worker' }, + ) + return 'root-returned-before-deadline' + }, + }, + 'task', + supervisorOpts({ + runId: 'settle-until-explicit-deadline', + budget: { maxIterations: 2, maxTokens: 2000, deadlineMs: 25 }, + childSettleGraceMs: null, + }), + ) + + await vi.advanceTimersByTimeAsync(24) + expect(vi.getTimerCount()).toBeGreaterThan(0) + + await vi.advanceTimersByTimeAsync(1) + const result = await run + expect( + result.tree.nodes.some((node) => node.label === 'worker' && node.status === 'done'), + ).toBe(false) + expect(vi.getTimerCount()).toBe(0) + neverSettles.resolve() + } finally { + vi.useRealTimers() + } + }) + it('still cascades the abort when the grace expires before the child settles', async () => { const neverSettles = deferred() const supervisor = createSupervisor()