diff options
| author | t <t@tjp.lol> | 2026-08-17 23:35:36 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-08-18 00:40:46 -0600 |
| commit | 372ef8ff40991644ec2654c61328f31779f4ad21 (patch) | |
| tree | 9bc69a578995e14f154202badcdbe0021a39c467 /spec | |
| parent | 7f8fdd8e868fb5fad71eacdf0c4fd0a97fe9c6ee (diff) | |
Validation-pass fixes; DESIGN.md describes the shipped seam
jobs: a child whose wake pipe cannot be armed is refused up front instead of
started into a state nothing can wake (the luv-less drain loop survives only
for hosts without a loop); a closing child keeps its concurrency slot and
its id's exclusivity until the pump actually exits, so teardown can no
longer over-admit new children or let two turns share one session file.
Resume-metadata reads honor the plain-error contract on a malformed store.
workflow: the built-in schema subset validator is the only validator — the
jsonschema probe made behavior depend on an undeclared rock (see rockspec:
that dependency is deliberately rejected); dead exports and the unreachable
half of the structured-output guard are gone, keeping the empty-arguments
provider case.
DESIGN.md's seam sections now describe the shipped division: binding-level
async jobs and tool control, host-level resolve_model/ExtHost/turn events/
component handles, rock-level policy; protocol bodies run to completion on
the loop thread and cannot yield, cancellation is scoped to the stream that
opened it, and a child's compaction leaves protocol sessions alone.
Diffstat (limited to 'spec')
| -rw-r--r-- | spec/fake_ext.lua | 35 | ||||
| -rw-r--r-- | spec/test_jobs.lua | 64 | ||||
| -rw-r--r-- | spec/test_run.lua | 24 | ||||
| -rw-r--r-- | spec/test_workflow.lua | 47 |
4 files changed, 149 insertions, 21 deletions
diff --git a/spec/fake_ext.lua b/spec/fake_ext.lua index 5ec4c06..dfdfdb3 100644 --- a/spec/fake_ext.lua +++ b/spec/fake_ext.lua @@ -32,11 +32,12 @@ -- -- Settling. There is no event loop here, so a fake job settles on the Nth call -- to `job:result()`: `settle = N`, lowest first, which is the same ordering key --- the previous fake used for "first" awaits. `uv.pipe()` fails in this harness --- (see the stub below), so the job machinery takes its fd-less path and drains --- its jobs in place; that is what turns those polls into progress and keeps the --- specs plain assert scripts. `request_cancel` settles the next poll as --- cancelled, exactly like the binding's pump. +-- the previous fake used for "first" awaits. Real wake pipes are opened and +-- polled (the job machinery refuses to start a child without one), but nothing +-- ever runs the loop and nothing writes a wake byte; the specs call await from +-- a plain script, which cannot park, so awaiting drains its jobs in place and +-- those polls are what turn into progress. `request_cancel` settles the next +-- poll as cancelled, exactly like the binding's pump. -- -- Everything the host was asked to do is recorded on the returned handle: -- `spawns` (one record per child agent, in creation order, carrying the @@ -52,21 +53,17 @@ local M = {} -- luv stand-in, installed once when this module loads -- --------------------------------------------------------------------------- --- The harness has no event loop, so it has no pipes: `uv.pipe()` fails and the --- job machinery falls back to draining in place, which is the same path a host --- without luv takes. `fs_mkdir` is recorded rather than performed, so a spec can --- assert which directories a child store would need without touching the disk. --- Everything else delegates to the real luv, so the filesystem cases are --- unaffected. This has to happen at load time, not inside install(), because a --- module that resolves luv once at load would otherwise capture the real one. +-- `fs_mkdir` is recorded rather than performed, so a spec can assert which +-- directories a child store would need without touching the disk. Everything +-- else — pipes included — delegates to the real luv, so a spec exercises the +-- same wake-pipe arming production does. This has to happen at load time, not +-- inside install(), because a module that resolves luv once at load would +-- otherwise capture the real one. local made_dirs = {} do local ok, real = pcall(require, "luv") if ok and type(real) == "table" then package.loaded.luv = setmetatable({ - pipe = function() - return nil, "the spec harness provides no pipes" - end, fs_mkdir = function(path) made_dirs[#made_dirs + 1] = path return true @@ -162,7 +159,9 @@ conv_mt.__index = conv_mt conv_mt.__name = "fake.conversation" -- scripted = array of { role =, text = | blocks =, metadata = }. `record`, when --- given, is the child record seeded system messages are mirrored onto. +-- given, is the child record seeded system messages are mirrored onto. A +-- scripted message may instead carry `metadata_error =`, which is how the +-- binding reports a stored record it cannot decode: by raising. local function new_conversation(scripted, record) local messages = {} for index, message in ipairs(scripted or {}) do @@ -170,6 +169,7 @@ local function new_conversation(scripted, record) role = message.role or "user", blocks = message.blocks or { { type = "text", text = message.text or "" } }, metadata = message.metadata, + metadata_error = message.metadata_error, } end return setmetatable({ _messages = messages, _record = record }, conv_mt) @@ -194,6 +194,9 @@ function conv_mt:message_metadata(index) if message == nil then return nil end + if message.metadata_error then + error("panto: " .. message.metadata_error, 2) + end return message.metadata end diff --git a/spec/test_jobs.lua b/spec/test_jobs.lua index ce0525c..de36ab5 100644 --- a/spec/test_jobs.lua +++ b/spec/test_jobs.lua @@ -3,9 +3,10 @@ -- -- These cases drive the job machinery directly with hand-made fake jobs rather -- than through a child, so a failure here points at the gate and not at spawn --- policy. The fake jobs have no wake pipe (the harness has none), so awaiting --- drains them in place; `settle = N` means "settles on the Nth poll", which is --- how the ordering cases stay deterministic without an event loop. +-- policy. Real wake pipes are armed (a job that cannot get one is refused), but +-- nothing writes to them and no loop runs: a plain script cannot park, so +-- awaiting drains its jobs in place and `settle = N` means "settles on the Nth +-- poll", which is how the ordering cases stay deterministic without a loop. local fake = require("spec.fake_ext") local jobs = require("subagents.jobs") @@ -42,9 +43,7 @@ local function starter() local function start(name, spec) spec = spec or {} return jobs.start({ - label = name, id = spec.id, - one_shot = spec.one_shot, on_event = spec.on_event, build = function() built[#built + 1] = name @@ -261,4 +260,59 @@ return { assert(tostring(err):find("the host refused", 1, true), tostring(err)) end) end }, + + -- Under luv the wake pipe is not optional: without it nothing would ever + -- return the owner thread to the loop the child's own tool batches need. + { "a child whose wake pipe cannot be armed is refused instead of started", function() + local ok, uv = pcall(require, "luv") + if not ok or type(uv) ~= "table" then + return "skip", "luv is not installed" + end + with_jobs(function() + local start, built = starter() + uv.pipe = function() + return nil, "EMFILE" + end + local handle, err = start("alpha") + uv.pipe = nil -- back to the real luv through the harness metatable + assert(handle == nil, "a child with no wake pipe must not start") + assert(tostring(err):find("the subagent wake pipe could not be armed", 1, true), tostring(err)) + assert(#built == 0, "and its turn is never built") + end) + end }, + + -- close_all cannot join a pump that is still up, so that child is still + -- running against the same bound and still owns its session file. + { "a closing child holds its slot until it settles", function() + with_jobs(function() + local start, built = starter() + local closing = assert(start("alpha", { settle = 99 })) + jobs.close_all() + + local queued = assert(start("beta")) + assert(#built == 1, "the closing child still holds the only slot, saw " .. #built) + + jobs.await({ closing }, "all") + assert(#built == 2, "the slot comes back when the closing child settles") + + jobs.await({ queued }, "all") + local last = assert(start("gamma")) + assert(#built == 3, "and the bound is not leaked once everything has settled") + jobs.await({ last }, "all") + end, 1) + end }, + + { "a closing child still reports its id as active", function() + with_jobs(function() + local start = starter() + local handle = assert(start("alpha", { id = "child-1", settle = 99 })) + assert(jobs.active("child-1"), "a running child is active") + + jobs.close_all() + assert(jobs.active("child-1"), "a cancelled child still owns its session file") + + jobs.await({ handle }, "all") + assert(not jobs.active("child-1"), "the settle releases the id") + end) + end }, } diff --git a/spec/test_run.lua b/spec/test_run.lua index 706faff..e4bb038 100644 --- a/spec/test_run.lua +++ b/spec/test_run.lua @@ -254,6 +254,30 @@ return { end) end }, + -- The binding reports a stored record it cannot decode by raising. A + -- malformed message is skipped, and nothing here raises out to the tool. + { "stored metadata that cannot be decoded is skipped, not raised", function() + with_host(function(handle, profiles) + local stored = stored_reviewer() + for _, message in ipairs(stored) do + if message.metadata then + message.metadata = nil + message.metadata_error = "message_metadata: invalid JSON" + end + end + handle.add_session("0198-child", stored) + handle.queue({ output = "second turn" }) + + local text = run.handle({ id = "0198-child", prompt = "carry on" }, profiles) + assert(not text:find("Error:", 1, true), "a malformed record is not a tool error:\n" .. text) + has(text, "second turn") + has(text, "agent: ?") -- an unreadable manifest names no agent + assert(handle.spawns[1].model == "anthropic:sonnet", + "an unreadable stored default falls back to the primary's model, got " .. + tostring(handle.spawns[1].model)) + end) + end }, + { "a resumed child takes its agent name from the manifest", function() with_host(function(handle, profiles) handle.add_session("0198-child", stored_reviewer()) diff --git a/spec/test_workflow.lua b/spec/test_workflow.lua index c5ba06c..5d58c01 100644 --- a/spec/test_workflow.lua +++ b/spec/test_workflow.lua @@ -231,6 +231,53 @@ return { end) end }, + -- One validator, always the built-in one: a schema must not be judged by + -- whether an undeclared rock happens to be installed on this machine. + { "validation never consults the jsonschema rock", function() + if not json_available() then + return "skip", "dkjson is not installed" + end + local restore = package.loaded.jsonschema + package.loaded.jsonschema = { + generate_validator = function() + return function() + return false, "the rock must not be consulted" + end + end, + } + local ok, err = pcall(with_host, function(handle, profiles) + handle.queue_for("alpha", { structured_json = '{"items":["x"]}' }) + local result = workflow.execute(workflow.workflow(function(ctx) + return ctx:agent({ + agent = "alpha", + prompt = "split it", + output = { schema = ITEMS_SCHEMA }, + }):await() + end), "input", { profiles = profiles }) + assert(result.status == "completed", tostring(result.error)) + assert(result.output.items[1] == "x", "the built-in validator accepted it") + end) + package.loaded.jsonschema = restore + if not ok then + error(err, 0) + end + end }, + + { "a structured worker whose output tool carried no arguments fails as missing", function() + with_host(function(handle, profiles) + handle.queue_for("alpha", { structured_json = "" }) + local result = workflow.execute(workflow.workflow(function(ctx) + return ctx:agent({ + agent = "alpha", + prompt = "split it", + output = { schema = ITEMS_SCHEMA }, + }):await() + end), "input", { profiles = profiles }) + assert(result.status == "failed", tostring(result.status)) + has(result.error, "structured output missing") + end) + end }, + { "a structured worker that answers in prose fails", function() with_host(function(handle, profiles) handle.queue_for("alpha", { output = "prose, not a tool call" }) |
