refactor(v2): per-group filesystem init, persistent across spawns

Each group's on-disk state (CLAUDE.md, .claude-shared/, agent-runner-src/)
is now initialized exactly once at group creation and owned by the group
forever after. Spawn does only mounts — no copies, no settings.json
overwrites, no skill clobbers, no source resyncs.

Global memory composition switches from "host reads /workspace/global/CLAUDE.md
at bootstrap and stuffs it into systemPrompt.append" to "group CLAUDE.md
imports it via @/workspace/global/CLAUDE.md at the top." Edits to global
propagate instantly through the existing read-only mount; no copy, no
restart.

- src/group-init.ts: new initGroupFilesystem(group, opts?) — idempotent,
  populates groups/<folder>/, .claude-shared/, agent-runner-src/ only when
  paths don't already exist.
- src/container-runner.ts: buildMounts() calls init defensively at the
  top (catches existing groups on first spawn after this change), drops
  the inline settings.json write, skills cpSync loop, and agent-runner-src
  rm-then-copy. Just mounts now.
- src/delivery.ts: create_agent flow uses initGroupFilesystem with
  optional instructions, replacing the inline mkdirSync + writeFileSync.
- container/agent-runner/src/index.ts: drops GLOBAL_CLAUDE_MD reading.
  systemContext.instructions is now only the runtime-generated
  destinations addendum.
- scripts/migrate-group-claude-md.ts: one-shot migration that prepends
  the @-import to existing groups' CLAUDE.md. Skips if global doesn't
  exist or if the @-import is already present (regex match on the @ form
  to avoid false positives from prose mentions of the path).
- groups/main/CLAUDE.md: prepended by the migration.

Existing groups need a one-time wipe of their agent-runner-src/ dir so
init re-populates from current host source — done locally before this
commit. Future host-side updates to container/skills/ or
container/agent-runner/src/ won't auto-propagate; that's the trade-off
for unconditional persistence and will be covered by host-mediated
refresh tools in a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-13 14:17:07 +03:00
parent 8676c07448
commit 2e6dc21748
6 changed files with 188 additions and 59 deletions

95
src/group-init.ts Normal file
View File

@@ -0,0 +1,95 @@
import fs from 'fs';
import path from 'path';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { log } from './log.js';
import type { AgentGroup } from './types.js';
const GLOBAL_CLAUDE_IMPORT = '@/workspace/global/CLAUDE.md';
const DEFAULT_SETTINGS_JSON =
JSON.stringify(
{
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
},
null,
2,
) + '\n';
/**
* Initialize the on-disk filesystem state for an agent group. Idempotent —
* every step is gated on the target not already existing, so re-running on
* an already-initialized group is a no-op.
*
* Called once per group lifetime: at creation, or defensively from
* `buildMounts()` for groups that pre-date this code path. After init, the
* host never overwrites any of these paths automatically — agents own them.
* To pull in upstream changes, use the host-mediated reset/refresh tools.
*/
export function initGroupFilesystem(
group: AgentGroup,
opts?: { instructions?: string },
): void {
const projectRoot = process.cwd();
const initialized: string[] = [];
// 1. groups/<folder>/ — group memory + working dir
const groupDir = path.resolve(GROUPS_DIR, group.folder);
if (!fs.existsSync(groupDir)) {
fs.mkdirSync(groupDir, { recursive: true });
initialized.push('groupDir');
}
// groups/<folder>/CLAUDE.md — written once, then owned by the group
const claudeMdFile = path.join(groupDir, 'CLAUDE.md');
if (!fs.existsSync(claudeMdFile)) {
const body = [GLOBAL_CLAUDE_IMPORT, '', opts?.instructions ?? `# ${group.name}`].join('\n') + '\n';
fs.writeFileSync(claudeMdFile, body);
initialized.push('CLAUDE.md');
}
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true });
initialized.push('.claude-shared');
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
}
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
const skillsSrc = path.join(projectRoot, 'container', 'skills');
if (fs.existsSync(skillsSrc)) {
fs.cpSync(skillsSrc, skillsDst, { recursive: true });
initialized.push('skills/');
}
}
// 3. data/v2-sessions/<id>/agent-runner-src/ — per-group source copy
const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', group.id, 'agent-runner-src');
if (!fs.existsSync(groupRunnerDir)) {
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
if (fs.existsSync(agentRunnerSrc)) {
fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true });
initialized.push('agent-runner-src/');
}
}
if (initialized.length > 0) {
log.info('Initialized group filesystem', {
group: group.name,
folder: group.folder,
id: group.id,
steps: initialized,
});
}
}