fix(claw): mount group folder and sessions into container
claw was running containers with no volume mounts, so the agent always saw an empty /workspace/group. Add build_mounts() to replicate the same bind-mounts that container-runner.ts sets up (group folder, .claude sessions, IPC dir, agent-runner source, and project root for main). Also includes upstream fix from qwibitai/nanoclaw#1368: graceful terminate() before kill() on output sentinel, and early return after a successful structured response so exit code stays 0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -121,8 +121,48 @@ def find_group(groups: list[dict], query: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -> None:
|
||||
cmd = [runtime, "run", "-i", "--rm", image]
|
||||
def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]:
|
||||
"""Return list of (host_path, container_path, readonly) tuples."""
|
||||
groups_dir = NANOCLAW_DIR / "groups"
|
||||
data_dir = NANOCLAW_DIR / "data"
|
||||
sessions_dir = data_dir / "sessions" / folder
|
||||
ipc_dir = data_dir / "ipc" / folder
|
||||
|
||||
# Ensure required dirs exist
|
||||
group_dir = groups_dir / folder
|
||||
group_dir.mkdir(parents=True, exist_ok=True)
|
||||
(sessions_dir / ".claude").mkdir(parents=True, exist_ok=True)
|
||||
for sub in ("messages", "tasks", "input"):
|
||||
(ipc_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
agent_runner_src = sessions_dir / "agent-runner-src"
|
||||
project_agent_runner = NANOCLAW_DIR / "container" / "agent-runner" / "src"
|
||||
if not agent_runner_src.exists() and project_agent_runner.exists():
|
||||
import shutil
|
||||
shutil.copytree(project_agent_runner, agent_runner_src)
|
||||
|
||||
mounts: list[tuple[str, str, bool]] = []
|
||||
if is_main:
|
||||
mounts.append((str(NANOCLAW_DIR), "/workspace/project", True))
|
||||
mounts.append((str(group_dir), "/workspace/group", False))
|
||||
mounts.append((str(sessions_dir / ".claude"), "/home/node/.claude", False))
|
||||
mounts.append((str(ipc_dir), "/workspace/ipc", False))
|
||||
if agent_runner_src.exists():
|
||||
mounts.append((str(agent_runner_src), "/app/src", False))
|
||||
return mounts
|
||||
|
||||
|
||||
def run_container(runtime: str, image: str, payload: dict,
|
||||
folder: str | None = None, is_main: bool = False,
|
||||
timeout: int = 300) -> None:
|
||||
cmd = [runtime, "run", "-i", "--rm"]
|
||||
if folder:
|
||||
for host, container, readonly in build_mounts(folder, is_main):
|
||||
if readonly:
|
||||
cmd += ["--mount", f"type=bind,source={host},target={container},readonly"]
|
||||
else:
|
||||
cmd += ["-v", f"{host}:{container}"]
|
||||
cmd.append(image)
|
||||
dbg(f"cmd: {' '.join(cmd)}")
|
||||
|
||||
# Show payload sans secrets
|
||||
@@ -167,7 +207,12 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
|
||||
dbg("output sentinel found, terminating container")
|
||||
done.set()
|
||||
try:
|
||||
proc.kill()
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
dbg("graceful stop timed out, force killing container")
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return
|
||||
@@ -197,6 +242,8 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
|
||||
stdout,
|
||||
re.DOTALL,
|
||||
)
|
||||
success = False
|
||||
|
||||
if match:
|
||||
try:
|
||||
data = json.loads(match.group(1))
|
||||
@@ -206,6 +253,7 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
|
||||
session_id = data.get("newSessionId") or data.get("sessionId")
|
||||
if session_id:
|
||||
print(f"\n[session: {session_id}]", file=sys.stderr)
|
||||
success = True
|
||||
else:
|
||||
print(f"[{status}] {data.get('result', '')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -215,6 +263,9 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
|
||||
# No structured output — print raw stdout
|
||||
print(stdout)
|
||||
|
||||
if success:
|
||||
return
|
||||
|
||||
if proc.returncode not in (0, None):
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
@@ -273,6 +324,7 @@ def main():
|
||||
# Resolve group → jid
|
||||
jid = args.jid
|
||||
group_name = None
|
||||
group_folder = None
|
||||
is_main = False
|
||||
|
||||
if args.group:
|
||||
@@ -281,6 +333,7 @@ def main():
|
||||
sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.")
|
||||
jid = g["jid"]
|
||||
group_name = g["name"]
|
||||
group_folder = g["folder"]
|
||||
is_main = g["is_main"]
|
||||
elif not jid:
|
||||
# Default: main group
|
||||
@@ -288,6 +341,7 @@ def main():
|
||||
if mains:
|
||||
jid = mains[0]["jid"]
|
||||
group_name = mains[0]["name"]
|
||||
group_folder = mains[0]["folder"]
|
||||
is_main = True
|
||||
else:
|
||||
sys.exit("error: no group specified and no main group found. Use -g or -j.")
|
||||
@@ -311,7 +365,9 @@ def main():
|
||||
payload["resumeAt"] = "latest"
|
||||
|
||||
print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr)
|
||||
run_container(runtime, args.image, payload, timeout=args.timeout)
|
||||
run_container(runtime, args.image, payload,
|
||||
folder=group_folder, is_main=is_main,
|
||||
timeout=args.timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
45
src/claw-skill.test.ts
Normal file
45
src/claw-skill.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('claw skill script', () => {
|
||||
it('exits zero after successful structured output even if the runtime is terminated', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claw-skill-test-'));
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const runtimePath = path.join(binDir, 'container');
|
||||
fs.writeFileSync(
|
||||
runtimePath,
|
||||
`#!/bin/sh
|
||||
cat >/dev/null
|
||||
printf '%s\n' '---NANOCLAW_OUTPUT_START---' '{"status":"success","result":"4","newSessionId":"sess-1"}' '---NANOCLAW_OUTPUT_END---'
|
||||
sleep 30
|
||||
`,
|
||||
);
|
||||
fs.chmodSync(runtimePath, 0o755);
|
||||
|
||||
const result = spawnSync(
|
||||
'python3',
|
||||
['.claude/skills/claw/scripts/claw', '-j', 'tg:123', 'What is 2+2?'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
NANOCLAW_DIR: tempDir,
|
||||
PATH: `${binDir}:${process.env.PATH || ''}`,
|
||||
},
|
||||
timeout: 15000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.stdout).toContain('4');
|
||||
expect(result.stderr).toContain('[session: sess-1]');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user