summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-17 23:35:36 -0600
committert <t@tjp.lol>2026-08-18 00:40:46 -0600
commit372ef8ff40991644ec2654c61328f31779f4ad21 (patch)
tree9bc69a578995e14f154202badcdbe0021a39c467 /spec
parent7f8fdd8e868fb5fad71eacdf0c4fd0a97fe9c6ee (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.lua35
-rw-r--r--spec/test_jobs.lua64
-rw-r--r--spec/test_run.lua24
-rw-r--r--spec/test_workflow.lua47
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" })