后台作业与持久终端

前台工具调用之外,模型还需要两类长生命周期的执行体:跑完即终的后台作业,和跨多次输入保持状态的交互式终端。 对应实现: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 会话事实。

下一步

在 GitHub 上编辑此页