21 KiB
ADR-0003:Executor 端口与 LocalProcessExecutor
- 状态:Proposed
- 日期:2026-07-18
- RFC:QL-RFC-0001
- 决策范围:任务执行生命周期、进程适配、输出流、取消与能力协商
1. 决策摘要
QingLong 3.0 的所有用户任务必须通过 Executor 端口启动、观察和停止。首个实现为 LocalProcessExecutor,它在当前节点创建独立进程组、流式转发 stdout/stderr、支持协作取消与超时升级,并返回统一的 ExecutionResult。
2.x 的 task.sh、前后置命令、工作目录、环境加载和语言识别暂不重写,而是作为 shell 命令载荷运行在 LocalProcessExecutor 内。新领域代码不得直接依赖 cross-spawn、ChildProcess、PID、ps-tree 或 Shell 回调 API。
本 ADR 只建立端口和本机执行适配器,不把现有 Cron/Scheduler 切换到新实现。接管生产流量必须经过 ADR-0002 的 origin 级 Feature Flag、Shadow Run 和回滚门禁。
2. 上下文与现状
当前执行逻辑至少分布在以下路径:
- 系统或 Node Scheduler:
addCron -> runCron -> cross-spawn。 - 面板手动运行:
CronService.run -> runSingle -> cross-spawn。 - Script、Subscription、System:
ScheduleService.runTask -> cross-spawn。 - 停止:多个 Service 直接调用
killTask、killAllTasks或通过命令查 PID。 - 状态:
task.sh -> shell/api.sh -> /open/crons/status -> CronService.status反向写回 Crontab 和 RunningInstance。 - 日志:部分路径由
task.sh写文件,部分路径由 Node 捕获 stdout/stderr 写入LogStreamManager或 WebSocket。
这会产生以下问题:
- spawn 成功、状态写入和 Shell 回调之间没有统一事实边界。
- PID 是实现细节,却被 API、数据库、停止逻辑和 UI 同时依赖。
error、exit、close、超时和人工停止缺少统一映射。- 多条路径分别处理日志,无法统一背压、截断、脱敏和 Artifact 归档。
- 进程重启后内存队列丢失,PID 复用还可能导致错误终止无关进程。
- Docker、Kubernetes 和远程 Worker 无法复用本地进程语义。
- AI、MCP Tool 或插件如果直接 spawn,会形成绕过 Policy 与 Run 审计的执行旁路。
3. 目标与非目标
3.1 目标
- 为本机进程、容器、Kubernetes Job 和远程 Worker 提供一致生命周期。
- 将安全的任务描述与含 Secret 的运行上下文分离。
- 支持 argv 与兼容 shell 两种命令,不强迫首切片重写
task.sh。 - 所有输出以有界、可背压的流交给 Artifact/日志端口。
- 取消、超时和强杀具有确定语义,重复调用保持幂等。
- edge 无需 Docker、systemd、cgroup 或外部服务即可执行本地任务。
- cluster-control 可以只调度,不需要本机用户任务执行能力。
3.2 非目标
- 本 ADR 不决定 Run 队列 claim、Worker lease 或 PostgreSQL 锁策略。
- 不在首切片实现 DockerExecutor、KubernetesExecutor 或 RemoteWorkerExecutor。
- 不承诺在所有平台强制 CPU/内存限额;能力不足时必须显式拒绝 required policy。
- 不在首切片替换
task.sh的语言、依赖和环境加载逻辑。 - 不允许通过 Executor 隐式获得 root、Docker socket 或宿主机任意目录权限。
4. 核心模型
4.1 ExecutionSpec
ExecutionSpec 是可审计的执行意图快照,必须能由 Task revision 重建,不保存 Secret 明文。
type ExecutionCommand =
| { kind: 'argv'; file: string; args: readonly string[] }
| { kind: 'shell'; command: string; shell?: string };
interface ExecutionSpec {
runId: string;
attemptId: string;
projectId: string;
taskId: string;
taskRevision: string;
command: ExecutionCommand;
workingDirectory?: string;
environmentPolicy: 'inherit' | 'isolated';
timeoutMs?: number;
terminationGraceMs: number;
resourcePolicy?: ExecutionResourcePolicy;
}
规则:
argv是 3.x 新任务默认形态,不经过 Shell 字符串解析。shell只用于 2.x 兼容、用户显式 Shell 任务或必须使用管道/重定向的任务。- Shell 路径属于 Executor 配置或显式 spec;不得把宿主机探测结果写回 Task revision。
workingDirectory必须由上层按 Project/Package 授权校验;Executor 只执行已授权的绝对路径。- timeout 和 grace period 必须有上限,禁止无限等待取消。
4.2 ExecutionContext
ExecutionContext 只在本次 Attempt 内存中存在,承载动态能力和敏感值:
interface ExecutionContext {
environment: Readonly<Record<string, string>>;
signal?: AbortSignal;
output: ExecutionOutputSink;
}
- SecretStore 在 Policy 通过后解析 SecretRef,并只通过
environment或受控文件挂载交给 Executor。 - Context 不进入 RunEvent、普通日志、Trace attribute 或异常序列化。
- Executor 不记录完整 command environment;诊断日志只允许键名和已脱敏摘要。
4.3 ExecutionHandle
Handle 分为可持久化描述和进程内私有句柄:
interface ExecutionHandle {
id: string;
executorType: string;
runId: string;
attemptId: string;
startedAtMs: number;
pid?: number;
completion: Promise<ExecutionResult>;
}
- 上层只持有公共字段和 completion;
ChildProcess等对象留在 adapter 内部。 pid只用于本机诊断和兼容投影,不是跨重启身份。- 持久化恢复至少需要 executor handle、worker ID、lease/fencing token 或本机进程启动指纹;只凭 PID 不得恢复强杀权限。
4.4 ExecutionResult
type ExecutionOutcome =
| 'succeeded'
| 'failed'
| 'cancelled'
| 'timed_out'
| 'lost';
interface ExecutionResult {
outcome: ExecutionOutcome;
startedAtMs: number;
finishedAtMs: number;
exitCode?: number;
signal?: string;
errorCode?: string;
errorSummary?: string;
}
映射规则:
| 条件 | outcome | 说明 |
|---|---|---|
| 正常 close,exit code 0 | succeeded | 必须等待 stdout/stderr sink drain 完成 |
| 正常 close,非 0 | failed | 保留 exit code |
| 用户或 Policy 请求停止 | cancelled | 即使 task.sh 将 SIGTERM 映射为 exit 1 |
| timeout 触发停止 | timed_out | 即使最终由 SIGKILL 结束 |
| spawn 失败 | start 抛 ExecutorStartError |
RunAttempt 尚未进入 running |
| Executor/Worker 失联且无法确认进程事实 | lost | 不猜测 succeeded/failed |
5. Executor 端口
interface Executor {
readonly type: string;
capabilities(): ExecutorCapabilities;
start(spec: ExecutionSpec, context: ExecutionContext): Promise<ExecutionHandle>;
stop(handle: ExecutionHandle, reason: StopReason): Promise<StopResult>;
inspect(handle: ExecutionHandle): Promise<ExecutionInspection>;
}
5.1 start
- 只有操作系统确认
spawn成功后才返回 Handle。 - 返回前必须注册 stdout、stderr、error、close、timeout 和 AbortSignal 监听器,避免短进程事件丢失。
start不等待任务完成;完成事实只能由handle.completion获得。- 同一 Attempt 不允许并发调用两次 start。幂等由 RunService/dispatch command dedupe 保证,而不是由本机 PID 猜测。
5.2 stop
- stop 对已结束或正在停止的 Handle 幂等。
- 首先发送 TERM;超过
terminationGraceMs后发送 KILL。 - POSIX 本机任务默认创建独立进程组并终止整个组,避免只杀 Shell 父进程。
- 无进程组能力的平台必须声明 capability,并使用经过测试的进程树 fallback。
- stop 返回信号是否已发送/升级,不直接伪造 Run 终态;终态来自 completion 或 Reconciler。
5.3 inspect
- 首版 LocalProcessExecutor 只保证检查本进程持有的 Handle。
- 控制进程重启后,旧 Handle 进入
unknown,由 Reconciler 根据持久化指纹决定lost或重新附着。 - 不允许通过
ps | grep command把任意相似命令重新认领为当前 Attempt。
6. 输出、日志与背压
ExecutionOutputSink.write 接收 {stream, chunk, observedAtMs}。LocalProcessExecutor 使用异步迭代读取 stdout/stderr,并在读取下一块前等待 sink,利用 Node stream pause/resume 形成背压。
必须满足:
- 不在内存中累计完整 stdout/stderr。
- sink 写入按单 stream 保序;stdout 与 stderr 不承诺全局顺序。
- completion 在两个 stream drain 后才完成。
- sink 失败记为稳定错误码;默认不立即杀死用户进程,但结果必须暴露日志不完整诊断并触发告警。
- 脱敏、行截断、总量配额和 Artifact 分片由输出层执行,Executor 只保证流式和背压。
- 禁止把 stdout/stderr chunk 写入 RunEvent;RunEvent 只记录 Artifact 引用和摘要。
7. 超时与取消
取消来源统一映射为:
userpolicyshutdownreconciletimeout
状态顺序:
stateDiagram-v2
[*] --> starting
starting --> running: OS spawn confirmed
starting --> failed: spawn error
running --> stopping: stop or AbortSignal
running --> stopping: timeout
stopping --> exited: TERM observed
stopping --> killing: grace elapsed
killing --> exited: KILL observed
running --> exited: natural close
exited --> [*]
- Executor 自己维护 timeout timer,Run Reconciler 仍需处理控制进程崩溃后的超时恢复。
AbortSignal只触发一次取消,不允许监听器泄漏。- 时间采用注入 Clock,持久化为 epoch milliseconds;测试使用虚拟时钟或短有界窗口。
8. LocalProcessExecutor 决策
8.1 进程创建
- 使用项目现有
cross-spawn,避免新增依赖。 - argv 命令直接传
file + args;shell 命令使用显式 Shell。 - POSIX 默认
detached: true创建独立进程组,但不unref,确保父进程观察 close。 - 当前孵化实现的 stdio 固定为 pipe/pipe/pipe,不支持继承宿主 TTY;进入跨重启 Primary 前按 ADR-0007 改为 runtime-owned direct-file Artifact,不能把父进程 pipe 作为唯一日志持有者。
environmentPolicy=inherit用于 Legacy;isolated只注入最小系统变量和 context environment。
8.2 资源策略
Capabilities 必须分别声明:
- timeout
- process-group termination
- working directory
- isolated environment
- CPU limit
- memory limit
- filesystem isolation
- network isolation
LocalProcessExecutor 在普通 edge 环境只承诺前四项。若 spec 要求当前节点不支持的 required limit,start 在 spawn 前以 EXECUTOR_CAPABILITY_UNAVAILABLE 失败;不得静默降级为无限制执行。
8.3 安全边界
shell命令不是安全沙箱,不能承载未信任的 AI 生成文本。- Agent/MCP/插件产生的命令必须先经过 Tool Registry、Policy 和 Approval,并优先转换为 argv 或受控 Package Script。
- LocalProcessExecutor 不授予额外权限;它继承 ql-core 用户拥有的宿主权限。
- 高风险或不可信任务必须选择提供 filesystem/network/resource isolation 的 Executor。
9. 与 Run 状态机的事务边界
数据库事务不能包含 spawn 或信号发送。Dispatch 采用可恢复协调:
- 短事务将 Run 从 queued 转为 dispatching,创建 claimed Attempt 和 dispatch command/dedupe key。
- 事务提交后调用
Executor.start。 - spawn 成功后,短事务记录 executor handle/PID,并将 Attempt starting -> running、Run dispatching -> running。
- spawn 失败时,短事务将 Attempt 和 Run 转为 failed/retry_wait。
- completion 产生完成命令,再由 RunCommandService 原子更新 Run/Attempt/Event。
崩溃窗口由 Reconciler 处理:
- dispatching 且无 handle:按 dedupe/retry policy 重新 dispatch 或失败。
- 有 handle 但未 running:inspect 后补写 running 或标记 lost。
- running 但控制进程丢失:只凭 PID 不重连,需启动指纹或 Worker fencing。
10. Legacy 兼容策略
首阶段 LegacyLocalExecutor/兼容构造器负责把当前 Crontab 转为:
shell command:
real_time=<...> no_tee=<...> ID=<cronId>
task_before='<...>' task_after='<...>' work_dir='<...>'
task <legacy command>
必须保留:
.js/.mjs/.py/.pyc/.sh/.ts识别。now/conc/desi参数。task_before、task_after、work_dir、log_name。NODE_OPTIONS、PYTHONPATH和依赖路径。- 当前日志目录兼容。
但以下行为逐步退出:
- Shell 回调直接决定 Run 终态。
- API 根据命令字符串
ps | grep查 PID。 - 多个 Service 各自维护 spawn 和 kill 逻辑。
- Crontab 的单个
pid/status/log_path代表全部并发实例。
Shadow 阶段 Shell 回调仍更新 2.x 表,Executor completion 只写 Shadow Run;Primary 阶段 Run 成为事实源,Shell 回调降级为带 callback token/sequence 的兼容事件。
11. Deployment Profile
| Profile | LocalProcessExecutor | 其他 Executor | 说明 |
|---|---|---|---|
| edge | 默认启用 | 默认禁用 | 无额外守护进程;低内存、单机进程 |
| standalone | 默认启用 | Docker 可选 | 本机仍可独立完成全部核心任务 |
| cluster-control | 默认禁用 | Remote/Kubernetes | 控制面不应隐式执行用户代码 |
| worker | 按 capability 启用 | Docker/Kubernetes 可选 | 向控制面上报能力和资源预算 |
Executor Registry 按任务要求、节点 capability、Policy 和资源预算选择实现;不得仅按字符串名称信任 Worker 自报能力。
12. 被否决方案
12.1 继续由每个 Service 直接 spawn
无法统一恢复、审计、取消和集群执行,拒绝。
12.2 直接删除 task.sh 并在 Node 重写全部行为
一次性破坏脚本参数、环境、日志和社区兼容,首阶段拒绝。长期可把稳定能力逐步下沉为 argv adapter。
12.3 只用 PID 作为 Handle
PID 可复用、无法跨 Worker、不能表达容器/Job,拒绝。
12.4 所有命令都使用 shell string
难以正确引用并放大注入风险。只允许兼容路径和显式 Shell Task。
12.5 LocalProcessExecutor 静默忽略资源限制
会让同一策略在 edge 与 cluster 上产生不同安全结果。required capability 不满足时必须失败。
13. 实施切片
PR-3A:纯端口与本机 adapter
- 定义 ExecutionSpec、Context、Handle、Result、Capabilities 和错误类型。
- 实现 LocalProcessExecutor,不连接现有 Service。
- 使用真实短进程完成 argv、shell、stdout/stderr、非零退出测试。
- 使用可注入 terminator 验证 cancel/timeout/幂等与升级语义。
PR-3B:Legacy spec builder
- 只把 Crontab snapshot 转换为兼容 ExecutionSpec。
- 建立特殊字符、前后置、工作目录、日志名、参数模式 snapshot test。
- 不执行用户任务,不改变 makeCommand。
PR-3C:Shadow Executor
- 按 execution origin 开启 flag。
- 现有进程仍为 owner,新 Executor 只观察时不得重复 spawn。
- 真正 shadow Run 记录来自现有 spawn observer,而不是第二次执行任务。
14. 验收门禁
进入 PR-4 Shadow Run 前必须证明:
- argv 与 shell 模式均保留参数边界。
- start 只在 spawn 确认后返回,spawn error 不产生 running。
- stdout/stderr 大输出测试无完整缓冲和无事件丢失。
- exit 0、非 0、signal、cancel、timeout 映射稳定。
- stop 重复调用幂等,TERM 超时后有界升级 KILL。
- completion 等待输出 sink drain,监听器和 timer 被清理。
- required capability 缺失时在 spawn 前拒绝。
- 环境和错误中不泄漏 Secret 值。
- Linux amd64、arm/v7、arm64 至少完成本机进程 smoke test;其余支持架构在镜像门禁验证。
- edge 基准记录 idle overhead、单任务 RSS、10000 行输出和取消时延。
- GitNexus 显示新 adapter 未被 2.x Controller/Scheduler 调用。
- 关闭全部 3.0 flag 时,现有行为和资源占用不变。
15. 后续 ADR
- ADR-0012:Remote Worker lease、fencing 与 reconnect。
- Docker/Kubernetes Executor 可在实现前分别新增 ADR。
- Artifact/Log Sink 的分片、保留和对象存储策略另行决定。
16. PR-5 Primary 编排器孵化约束
PR-5 首先新增不可从 2.x Controller、Scheduler 或 Shell callback 到达的 PrimaryRunOrchestrator,用于验证 Run 事务与 Executor 外部副作用之间的编排边界。它不是 Feature Flag,也不表示 LocalProcessExecutor 已接管生产流量。
16.1 spawn 前事实
调用 Executor.start() 之前必须已经持久化:
- runtime-owned Run 与 claimed Attempt;
run.created与run.queued;run.dispatching与attempt.starting。
任一写入失败都必须在 spawn 前失败关闭。这里不要求把多个状态转换压缩为一个事务;分事务保留可审计状态,启动 Reconciler 负责识别进程尚未产生的 dispatching/starting 残留。
16.2 spawn 后 ownership
Executor 返回后,编排器必须验证 handle 的 Run ID、Attempt ID 和 Executor type 与持久化聚合一致,再写入 attempt.running 的 handle/PID 和 run.running。若身份校验失败,或任一 running ownership 写入失败:
- 以
reconcile原因请求 owning Executor stop; - 将仍可更新的 Attempt 和 Run 写为
lost; - 向调用者返回包含 Run 引用但不包含命令、环境或路径的类型化错误;
- 即使 stop 或 lost 写入再次失败,也不得自动启动 Legacy 副本。
16.3 completion 与取消
- success、failed、cancelled、timed_out 和 lost 由 Executor completion 映射到 Attempt,再映射到 Run;两次转换继续使用 Run version 作为串行化边界。
- Executor completion Promise 异常拒绝视为 ownership 丢失,安全收敛为 lost,而不是让 Run 永久停留在 running。
- cancel 通过当前 owning Executor handle 执行;只有 Executor 报告最终 completion 后才写 Run 终态。
- 当前 active handle registry 只保存实际运行中的本进程任务,completion 后立即删除;跨 Worker cancel 与重启恢复仍由后续 Reconciler/lease 设计负责。
LocalProcessExecutor 的 live handle ID 继续服务当前进程内 stop/inspect;另提供可选 durable handle 用于重启核验。Linux durable handle 编码版本、boot ID、PID、start ticks 和 process group,不包含命令、环境、路径或日志。捕获失败不得阻塞当前任务启动,但这样的 Attempt 重启后只能保守 lost。
16.4 Primary fallback 边界
Legacy fallback 只允许发生在能够证明 Primary 尚未 spawn 的阶段。Executor 已被调用、返回 handle、handle 身份不一致、running ownership 写入失败或结果未知时,都必须 stop/reconcile,不得再执行 Legacy 命令。该约束防止一次用户请求产生两个真实进程。
幂等请求使用独立的 Primary lookup port,不扩展 Shadow/Legacy 共用的 Repository reader。编排器先查询 project_id + idempotency_key,但预检只用于快速返回;数据库唯一索引才是竞态下的最终裁决。唯一约束冲突后必须重新读取已有 Run ID 并返回 duplicate result,第二个请求的 Executor start 次数必须为零。配置了 idempotency key 却没有 lookup adapter 时在创建 Run 前失败关闭。
16.5 尚未通过的生产门禁
- 部署配置写入/审批入口、用户可见状态和操作回滚演练;
- 2.x 实时日志、停止 API 和异常重启契约的完整兼容验证;
- ADR-0007 的 direct-file log、completion receipt、durable deadline 与周期 completion supervisor;
- 多实例策略、跨 Worker cancel、回滚演练;
- 固定 edge 设备与 Linux 多架构的资源/进程 contract gate。
当前仅允许由默认关闭的 manual-only manifest canary 到达;在上述门禁完成前不得扩大到其他 origin 或默认流量。
16.6 有界启动 Reconciler 内核
当前孵化实现提供一次性 reconcileBatch,调用方负责启动接线和翻页:
- 默认每批 32、最大 64 个 runtime-owned dispatching/running Run;
- 使用
created_at_ms + run_id稳定游标,两次有界查询,不做全表常驻 watcher; - 每个候选串行核验和写入,避免 SQLite 并发写争用;
- active Attempt 行数超过每批两倍视为不安全数据,整批失败关闭,不移动游标;
- 同一 Run 有多个 active Attempt 时只报告 ambiguous,不选择任意一个;
- 单个 OS probe 或事务失败只计入该候选,不中止后续候选。
协调规则如下:
- running Attempt 的 durable identity 仍匹配时,running Run 追加
run.reconciled;若 Run 停在 dispatching,则以 reconciler actor 转为 running。 - Attempt 停在 claimed/starting、缺少 handle、handle 无效、PID 不一致、boot/start ticks/process group 不一致、平台不支持或进程已消失时,Attempt 与 Run 收敛为 lost。
- probe 的暂时性 I/O 异常不立即改状态,留待下一批重试。
- Reconciler 只有 inspect 能力,不包含 signal/kill API,因此恢复扫描不可能误杀 PID 复用后的其他进程。
这仍不是完整的“进程重启后继续管理”:父进程退出后,新的 QingLong 进程不能重新连接原 stdout/stderr pipe,也不能从已退出进程取得可靠 exit code。durable identity 只能证明进程是否仍存在;ADR-0007 已决定采用 direct-file log、CompletionReceipt 和有界周期 Supervisor,但尚未实现。不得把一次 run.reconciled 误解为 completion 已可恢复。