后台作业与持久终端
前台工具调用之外,模型还需要两类长生命周期的执行体:跑完即终的后台作业,和跨多次输入保持状态的交互式终端。
对应实现:src/Tether.Jobs/ + .Local + .Tools 与 src/Tether.Terminal/ + .Local + .Tools。
作业:owner 作用域的进程注册表
public interface IJobService
{
ValueTask<JobInfo> StartAsync(AgentOwnerToken owner, JobSpec spec, CancellationToken ct = default);
ValueTask<JobPage> ReadAsync(AgentOwnerToken owner, string jobId, long cursor, int maxChars = 32_000, CancellationToken ct = default);
ValueTask<IReadOnlyList<JobInfo>> ListAsync(AgentOwnerToken owner, CancellationToken ct = default);
ValueTask<bool> WaitAsync(AgentOwnerToken owner, string jobId, TimeSpan timeout, CancellationToken ct = default);
ValueTask KillAsync(AgentOwnerToken owner, string jobId, CancellationToken ct = default);
IDisposable OnCompletion(AgentOwnerToken owner, Func<JobInfo, ValueTask> listener);
ValueTask DrainOwnerAsync(AgentOwnerToken owner, CancellationToken ct = default);
}
- Exact Agent owner 隔离。
AgentOwnerToken是 opaque、process-local 的 reference-identity capability,绑定一次 exact live Agent publication;同一SessionId的 replacement Agent 也会得到新 token。跨 owner 读、等、杀以JOB_FORBIDDEN/JOB_NOT_FOUND拒绝,token 不进入SessionEvent、YAML 或持久化 header。 - 非 Agent 调用必须显式。 真正的 host consumer 通过
AgentOwnerLease.CreateHost()创建独立 owner;模型工具没有local、字符串 owner 或隐式 fallback。 - 并发上限。 每个 owner 同时运行的作业数有上限(本地实现默认 8),超限以
JOB_LIMIT失败——模型可以随便起作业,进程不会被拉爆。 - 生命周期。
Running→Succeeded/Failed(带退出码)/Killed。IsTerminal区分活跃与终态。 - 输出是单消费者游标。
ReadAsync的 cursor 只前进、只能一个消费者:读过的字节随游标前进被消费,每 job 有保留上限(默认有字符数封顶),超出的前缀被丢弃。这让”边跑边读”与”事后补读”共用同一通道而不重复投递。 - 完成回调与投递边界。 Provider 的
OnCompletion对每个 job 只触发一次 callback,即使完成与订阅几乎同时发生;router 在投递前原子 claim,每个 completion 最多尝试投递一次。失败不会伪造成功或自动重试,job fact 仍可查询。 - Kill 是真实信号。
KillAsync先给前台进程组发真实中断,再终止整棵进程树——与 子进程 的树回收共用同一底座。
终端:跨输入保活的 PTY
public interface ITerminalService
{
ValueTask<TerminalInfo> OpenAsync(AgentOwnerToken owner, string? shell = null, CancellationToken ct = default);
ValueTask<TerminalSendResult> SendAsync(AgentOwnerToken owner, string id, string input, int settleMs = 500, CancellationToken ct = default);
ValueTask<TerminalCommandResult> ExecuteAsync(AgentOwnerToken owner, string id, string command, int timeoutMs = 120_000, CancellationToken ct = default);
ValueTask<TerminalChunk> ReadAsync(AgentOwnerToken owner, string id, long cursor, int maxChars = 32_000, CancellationToken ct = default);
ValueTask SignalAsync(AgentOwnerToken owner, string id, TerminalSignal signal, CancellationToken ct = default);
ValueTask CloseAsync(AgentOwnerToken owner, string id, CancellationToken ct = default);
ValueTask<IReadOnlyList<TerminalInfo>> ListAsync(AgentOwnerToken owner, CancellationToken ct = default);
ValueTask DrainOwnerAsync(AgentOwnerToken owner, CancellationToken ct = default);
}
与作业的三处本质区别:
- 状态跨 send 存活。 终端会话里的 shell 变量、当前目录、前台程序在一次
SendAsync之后都还在;这正是”持久”的含义。 - 单在途 send。 每个会话同时只允许一次
SendAsync,并发发送以TERMINAL_BUSY失败——否则两次输入的交错输出无法归因。 - 输出保留有界且可丢。
ReadAsync的游标是读指针;缓冲区超出保留上限时丢弃最旧前缀并把TerminalChunk.Lossy置真,消费者据此知道中间有洞,而不是以为看到了连续输出。
CloseAsync 会等整棵进程树安静下来再返回——终端里起的孙子进程不会成为孤儿。
SendAsync 返回输出、cursor、lossy 和 WaitReason:stdin_read 表示已证明前台组等待终端输入,inferred_idle 仅表示输出静默,timeout 表示达到总等待截止时间,session_exit 表示会话退出。静默预算从输入写完开始;发送前已有的同一 shell 等待不会直接作为本次输入已消费的证据。
Linux 通过前台进程组、各线程 syscall 和线程自己的 fd0 判断输入等待,兼容 x64/arm64 kernel ABI。普通 pipe、其他终端及无法读取的 /proc 都不能证明等待本终端输入;macOS/Windows 不模拟这项 Linux 证明。扫描后再次核对 root 身份,并重新读取输出时间和关闭状态,避免慢扫描发布过时结论。
POSIX 每次 signal 前重读进程身份,保留 start/session/state 校验。该复核缩短 PID 复用窗口;kill(pid) 本身仍不是绑定 start identity 的原子系统调用。
PTY 后端的平台差
POSIX 由仓库随附的 native broker 调用 openpty,启动
/bin/bash --noprofile --norc -i;Windows 通过 P/Invoke
CreatePseudoConsole 启动 pwsh -NoLogo -NoProfile。两端使用同一
OSC 133 nonce marker 协议确认命令完成,Ctrl+C 写入 PTY input,并在 close/dispose 时等待
整棵带 PID reuse fence 的进程树排干。
工具暴露
Tether.Jobs.Tools 与 Tether.Terminal.Tools 把两条 seam暴露给模型。模型侧看到的是普通工具调用与稳定失败码。
当前模型工具 terminal_send 调用 ExecuteAsync,以 nonce 判断命令完成并返回 exitCode/timedOut/shellReset。上述 SendAsync 等待原因属于输入服务合同,尚未通过该命令工具暴露;普通 terminal_read 也不伪造等待原因。
模型调用由 initiating Agent 的 AgentPipelineScope 创建 ToolExecution;ToolRegistry 只把该 execution 的 exact AgentOwnerToken 写入可信 invocation context。JobToolsPlugin 与 TerminalToolsPlugin 每次调用都从这里解析 owner,缺失、类型错误或 owner 已 retired 时 fail closed。因此注册工具的 plugin Context、bundle row、SessionId 和字符串都不能冒充资源身份。
完成路由与有界唤醒
默认发布组合在每次 Agent publication 上 attach JobCompletionRouter。它先订阅 completion,再回放 provider snapshot,quiet window 内的多个终态合并成一条 notice;provider 原子 claim 保证每个 completion 最多发起一次投递尝试,投递失败仍保留可查询 job facts,并通过 jobs/completion 错误路径和 awaited teardown 暴露。
- Agent 正在 Running 时,phase authority 把 notice 与 inbox 写入线性化到
NextStep,不消耗 wake permit。 - Agent 已 Idle 且策略为默认
wakeup时,notice 写入NextTurn并启动 driver。每个 exact Agent 默认最多连续唤醒 3 次;第 4 次及之后降级为不唤醒的NextStep注入。 quiet策略在 Idle 时只排队、不唤醒;两种策略在 Running 时都进入NextStep。只有实际被 inbox claim 的 user-authored 输入重置预算,plugin notice 不会重置。
Agent teardown 先 retire owner 阻止新操作,再 detach completion listener,排干 jobs 与 terminals,并等待 router timer、worker、sender 和错误发布 settle。provider/lifecycle HMR replacement 期间,authoritative Agent teardown contribution 仍能跨 listener 空窗回收已退出 owner;live Agent 的 completion gap 则由 subscribe-before-snapshot replay 补齐。
jobs 与 PTY 是进程内资源
持久化 transcript 记录工具调用、结果和 completion notice,但不会持久化 job registry、进程或 PTY。 进程重启后不能恢复旧 job/terminal,也不能把 terminal 跨 Agent 共享;cold resume 只恢复 durable 会话事实。