Files
nanoclaw/docs/answers/04-container-lifecycle.md

8.0 KiB
Raw Blame History

Q10-Q12: 容器生命周期


Q10: 启动 agent 容器时 mount 了哪些东西?

答案

Container mount 由 src/container-runner.ts:242-335buildMounts() 构建。容器内文件系统结构:

容器路径 宿主机来源 权限 用途
/workspace/ data/v2-sessions/<agentGroup>/<session>/ RW Session 目录:inbound.dboutbound.db.heartbeatoutbox/inbox/
/workspace/agent/ groups/<folder>/ RW Per-group 工作文件 + CLAUDE.local.md
/workspace/agent/container.json groups/<folder>/container.json RO 嵌套 RO 覆盖——agent 可读不能改line 276-278
/workspace/agent/CLAUDE.md groups/<folder>/CLAUDE.md RO 组合的 CLAUDE.mdspawn 时重新生成line 287-290
/workspace/agent/.claude-fragments/ groups/<folder>/.claude-fragments/ RO per-skill/per-MCP 指令片段
/workspace/global/ groups/global/ RO 共享全局记忆
/app/CLAUDE.md container/CLAUDE.md RO 共享基础 CLAUDE.md通过 .claude-shared.md symlink 导入
/home/node/.claude/ data/v2-sessions/<agentGroup>/.claude-shared/ RW Claude SDK 状态、settings.json、skill symlinks
/app/src/ container/agent-runner/src/ RO 共享 agent-runner TypeScript 源码
/app/skills/ container/skills/ RO 共享容器技能
额外 containerConfig.additionalMounts Provider-contributed mountsline 330

调用链

  1. spawnContainer()line 108buildMounts()line 134
  2. Pre-mount 初始化:
    • initGroupFilesystem(agentGroup)line 253幂等创建 groups/<folder>/CLAUDE.local.md.claude-shared/ 目录和 DB 行
    • syncSkillSymlinks()line 257根据 container.jsonskills 选择,在 .claude-shared/skills/ 下创建 symlink
    • composeGroupClaudeMd(agentGroup)line 261重新生成组合 CLAUDE.md
  3. Mount 按顺序组装line 267-333
  4. 所有 volume mount 进入 buildContainerArgs()line 447-453

边界情况

  • CLAUDE.md.claude-fragments/ 是嵌套 RO mount叠加在 RW group 目录上——agent 只能写 CLAUDE.local.md
  • container.json 单独 RO mount 防止 agent 修改自己的配置
  • Skill symlinks 指向容器内路径(/app/skills/<name>),在宿主机上是悬空符号链接,容器内有效

Q11: Agent 的 system prompt 是怎么拼出来的?

答案

Agent 的 system prompt 由三部分拼成:(A) 共享基础 CLAUDE.md(B) per-skill/per-MCP 指令片段,(C) 运行时 addendum身份 + destinations

Host 端组合spawn 时)

src/claude-md-compose.ts:43-136composeGroupClaudeMd()

  1. 共享基础 symlinkline 49-50groups/<folder>/.claude-shared.md/app/CLAUDE.md21 行通用 agent 指令交流风格、workspace、memory、conversation history

  2. Fragment 发现line 58-107

    • Skill fragmentsline 66-76container/skills/ 下任何有 instructions.md 的技能
    • 内置模块 fragmentsline 83-96container/agent-runner/src/mcp-tools/ 下的 .instructions.mdcli.instructions.mdcli_scope='disabled' 时被跳过
    • MCP server fragmentsline 100-107container.json 中外部 MCP server 的 instructions 字段,生成内联 fragment 文件
  3. Fragment 协调line 110-122删除不再需要的过期 fragment创建/更新 symlink

  4. 组合入口line 125-130写出 groups/<folder>/CLAUDE.md,只含 import 指令:

    @./.claude-shared.md
    @./.claude-fragments/skill-onecli-gateway.md
    @./.claude-fragments/skill-welcome.md
    @./.claude-fragments/module-cli.md
    

    Claude Code 跟随 @ import 解决所有 fragment

  5. Per-group 记忆line 132-135确保 CLAUDE.local.md 存在——这是唯一可写的 CLAUDE.md 文件

Container 端运行时 addendum

container/agent-runner/src/destinations.ts:82-92buildSystemPromptAddendum()

  • 身份line 85-87如果设置了 assistantName"You are <name>" + 自我介绍和签名指引
  • Destination mapline 94-130inbound.dbdestinations 表读取,生成 "Sending messages" 部分

各部分贡献

来源 内容 Agent 可修改?
container/CLAUDE.md(共享基础) 通用 agent 行为 RO mount
Skill instructions.md Per-skill 指引 RO
MCP tool .instructions.md 如何使用内置工具 RO
MCP server instructions 外部 MCP server 指引 仅 adminDB中
CLAUDE.local.md Per-group 记忆 (唯一可写)
buildSystemPromptAddendum() 身份 + destination map 生成时自动

Q12: 容器心跳怎么检测?进程活着但 poll loop 卡死了怎么发现?

答案

心跳是文件 touch 机制,不是 DB 写入,避免跨 mount DB 写入争用。

Container 端:心跳 touch

  • Path/workspace/.heartbeatcontainer/agent-runner/src/db/connection.ts:25
  • touchHeartbeat()connection.ts:156-168):用 fs.utimesSync() 更新文件 mtime。失败时回退 fs.writeFileSync()
  • 触发时机poll-loop.ts:361,在 for await (const event of query.events) 循环中——每个 SDK eventinitresulterrorprogress)都触发。意味着仅 agent 活跃流式回复时更新poll 轮空间歇不更新

Host 端:卡住检测

Host sweep 每 60s 运行(host-sweep.ts:61),对每个有运行容器的 session 调用 enforceRunningContainerSla()line 192 → line 228

两种检测层级decideStuckAction()line 82-118

1. 绝对天花板line 91-105heartbeat mtime 年龄 > max(30 min, 当前Bash超时)kill-ceiling

  • ABSOLUTE_CEILING_MS = 30 * 60 * 1000
  • 扩展 declaredBashMs:从 outbound.dbcontainer_state,如果当前工具是 Bash 且有 tool_declared_timeout_ms,天花板扩大到该值
  • 关键守护line 92-98heartbeatMtimeMs === 0(刚 spawn还没有 SDK activity→ 跳过

2. Claim-stuck per-messageline 107-115对每个 processing_ack 中的 processing 行,如果 (claim_age > tolerance)(heartbeat_mtime <= status_changed)kill-claim

  • CLAIM_STUCK_MS = 60sclaim 一条消息后 60s 内没有任何 heartbeat → poll loop 卡住
  • 条件 heartbeat_mtime <= claimedAt 恰好检测 "claim 了消息后没有任何生命迹象"

3. 容器不在运行line 199-201!isContainerRunningresetStuckProcessingRows() 用指数退避(基数 5s × 2^tries最多 5 次)重调度消息

处理器卡在 poll loop 的完整场景

Container 进程存活poll loop 卡住:
  poll-loop.ts:101 → markProcessing(ids) → DB: status='processing', status_changed=NOW
  poll-loop.ts:174 → config.provider.query(...) → 启动,但 SDK 挂起
  [没有 heartbeat touch因为没有 event 触发]
  ...
  Host sweep60s后
    → getProcessingClaims(outDb) → 发现 claimstatus_changed 很旧
    → heartbeatMtimeMs() → 返回旧 mtime挂起前最后一次 event 的)
    → decideStuckAction(): claimAge > 60s, heartbeat_mtime <= claimedAt
    → action: 'kill-claim' → killContainer() + resetStuckProcessingRows()

边界情况

  • 新 spawn 宽容期heartbeat 文件不存在时跳过天花板检查,但 claim-stuck 检查仍然处理 "claim 了消息但在门口卡住"
  • 每次 spawn 清 heartbeatcontainer-runner.ts:155):防止旧容器的过期 mtime 立即触发 kill
  • 孤儿 claim 清理line 319kill 后 deleteOrphanProcessingClaims() 清掉 outbound.db 中的 processing 行,防止 sweep 立即 kill 新容器
  • Bash 自定义超时:扩展容忍度,确保长运行 Bash 不被误杀