coxpit 4.5.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db/index.ts CHANGED
@@ -44,6 +44,7 @@ export async function ensureSchema(): Promise<void> {
44
44
  prompt TEXT NOT NULL DEFAULT '',
45
45
  status TEXT NOT NULL DEFAULT 'open',
46
46
  design_capture_id INTEGER,
47
+ outputs TEXT NOT NULL DEFAULT '[]',
47
48
  created_at INTEGER DEFAULT (unixepoch())
48
49
  );
49
50
  CREATE TABLE IF NOT EXISTS agent_runs (
@@ -98,4 +99,6 @@ export async function ensureSchema(): Promise<void> {
98
99
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN model TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
99
100
  try { await client.execute('ALTER TABLE tasks ADD COLUMN group_id INTEGER'); } catch { /* exists */ }
100
101
  try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
102
+ try { await client.execute("ALTER TABLE task_groups ADD COLUMN coord_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
103
+ try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
101
104
  }
package/src/db/schema.ts CHANGED
@@ -37,6 +37,7 @@ export const taskGroups = sqliteTable('task_groups', {
37
37
  id: integer('id').primaryKey({ autoIncrement: true }),
38
38
  kind: text('kind').notNull().default('goal'), // 'goal' | 'swarm'
39
39
  title: text('title').notNull(),
40
+ coordSessionId: text('coord_session_id').notNull().default(''), // L2 — 읽기전용 Ask 코디네이터의 재개 세션(--resume 키)
40
41
  createdAt: integer('created_at', { mode: 'timestamp' }),
41
42
  });
42
43
 
@@ -48,6 +49,7 @@ export const tasks = sqliteTable('tasks', {
48
49
  prompt: text('prompt').notNull().default(''),
49
50
  status: text('status').notNull().default('open'), // open | done
50
51
  designCaptureId: integer('design_capture_id'), // 선택 — 프롬프트에 DESIGN CONTEXT 주입
52
+ outputs: text('outputs').notNull().default('[]'), // 산출물 계약 — 선언한 타입 JSON 배열(answer|code|doc|page|file). 빈 배열 = 계약 없음.
51
53
  parentRunId: integer('parent_run_id'), // 에이전트 셀프 오케스트레이션 — 이 태스크를 발사한 run
52
54
  groupId: integer('group_id'), // task_groups — goal/swarm 형제 묶음(수동 태스크는 NULL)
53
55
  closedAt: integer('closed_at', { mode: 'timestamp' }), // 닫힌 시각(아카이브 정렬·표시)
@@ -13,6 +13,57 @@ import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
13
13
  import { broadcast } from './hub';
14
14
  import { getProvider, type Provider } from './providers';
15
15
 
16
+ // ── 산출물 계약(deliverable contract) ─────────────────────────
17
+ /** 산출물 타입 5종 — 태스크가 선언할 수 있는 계약 항목. */
18
+ export const OUTPUT_TYPES = ['answer', 'code', 'doc', 'page', 'file'] as const;
19
+ export type OutputType = (typeof OUTPUT_TYPES)[number];
20
+ const OUTPUT_SET = new Set<string>(OUTPUT_TYPES);
21
+
22
+ /** 임의 입력 → 유효한 산출물 타입 배열(중복 제거, 순서 보존). API·저장 공용. */
23
+ export function normalizeOutputs(input: unknown): OutputType[] {
24
+ if (!Array.isArray(input)) return [];
25
+ const seen = new Set<string>();
26
+ const out: OutputType[] = [];
27
+ for (const v of input) {
28
+ if (typeof v === 'string' && OUTPUT_SET.has(v) && !seen.has(v)) {
29
+ seen.add(v);
30
+ out.push(v as OutputType);
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** tasks.outputs(JSON 문자열) → 산출물 타입 배열. 파싱 실패는 빈 배열. */
37
+ export function parseOutputs(raw: string | null | undefined): OutputType[] {
38
+ if (!raw) return [];
39
+ try { return normalizeOutputs(JSON.parse(raw)); } catch { return []; }
40
+ }
41
+
42
+ /** 프롬프트에 붙는 Deliverables 블록(A3). declared 는 비어있지 않다. */
43
+ function deliverablesNote(declared: OutputType[]): string {
44
+ const human: Record<OutputType, string> = {
45
+ answer: 'an answer', code: 'code changes', doc: 'a Markdown doc',
46
+ page: 'an HTML page', file: 'a file',
47
+ };
48
+ const list = declared.map((t) => human[t]).join(', ');
49
+ return '\n\n--- COXPIT DELIVERABLES (required) ---\n' +
50
+ `Deliverables (required): ${list}.\n` +
51
+ 'Produce each as a real file in the repo (docs as .md, pages as .html). Register every ' +
52
+ 'deliverable in .coxpit/outputs.json as a JSON array of {path,type,title}. End with a final ' +
53
+ 'message stating the answer.\n' +
54
+ '--- END COXPIT DELIVERABLES ---';
55
+ }
56
+
57
+ /** 계산된 출력 카드(computeRunOutputs 반환) — 보드가 오른쪽 컬럼에 렌더. */
58
+ export interface RunOutputCard {
59
+ type: OutputType;
60
+ title: string;
61
+ path?: string;
62
+ required: boolean;
63
+ present: boolean;
64
+ meta: string;
65
+ }
66
+
16
67
  /** 에이전트 실행 커맨드. 드라이런=모의 stream-json + 실제 파일 1건 변경. */
17
68
  function agentCommand(provider: Provider, prompt: string, real: boolean, model = ''): string {
18
69
  if (real) return provider.launchCmd(prompt, model || undefined);
@@ -59,6 +110,11 @@ export function resolveAgentToken(token: string): number | null {
59
110
  return agentTokens.get(token) ?? null;
60
111
  }
61
112
 
113
+ /** run 이 지금 살아 있는가(자식 프로세스 보유). aggregate 뷰의 live/steerable 판정용. */
114
+ export function isRunLive(runId: number): boolean {
115
+ return liveChildren.has(runId);
116
+ }
117
+
62
118
  /** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
63
119
  * 파일 기반: 기본 권한(claude acceptEdits · codex workspace-write)이 네트워크를 막아도
64
120
  * 파일 쓰기는 되므로, spawn 요청을 워크트리의 .coxpit/spawn.json 으로 받는다. */
@@ -236,6 +292,11 @@ async function loadContext(runId: number): Promise<RunContext | null> {
236
292
  }
237
293
  }
238
294
 
295
+ // 산출물 계약 — task.outputs 가 비어있지 않으면 Deliverables 블록 주입(디자인 캡처와 같은 시임).
296
+ // launchRun/launchGroupTask 는 모두 loadContext 를 거치므로 여기서 한 번에 커버.
297
+ const declared = parseOutputs(task.outputs);
298
+ if (declared.length) prompt += deliverablesNote(declared);
299
+
239
300
  return {
240
301
  runId,
241
302
  machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
@@ -549,6 +610,152 @@ export async function getRunDocs(runId: number): Promise<{
549
610
  return { ok: true, docs };
550
611
  }
551
612
 
613
+ // ── computeRunOutputs — 산출물 카드 계산(A2/A4) ──────────────────
614
+ interface OutputsManifestItem { path?: string; type?: string; title?: string }
615
+
616
+ /** run 의 최종 답변 텍스트 — result 이벤트(payload JSON) 우선, 없으면 exitSummary. */
617
+ async function runAnswerText(run: typeof agentRuns.$inferSelect): Promise<string> {
618
+ const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, run.id));
619
+ for (let i = evs.length - 1; i >= 0; i--) {
620
+ if (evs[i]!.kind !== 'result') continue;
621
+ try {
622
+ const o = JSON.parse(evs[i]!.payload) as { result?: string };
623
+ if (typeof o.result === 'string' && o.result.trim()) return o.result.trim();
624
+ } catch { /* 비-JSON result — exitSummary 로 폴백 */ }
625
+ }
626
+ return (run.exitSummary || '').trim();
627
+ }
628
+
629
+ /** 확장자 → 파생 카드 타입. code/file 은 group 만, 실제 타입은 code|page|doc|file. */
630
+ function extType(p: string): OutputType {
631
+ if (/\.(md|markdown)$/i.test(p)) return 'doc';
632
+ if (/\.(html?|htm)$/i.test(p)) return 'page';
633
+ if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(p)) return 'file';
634
+ return 'code';
635
+ }
636
+
637
+ /** status --porcelain 한 줄 → 상대경로(따옴표·상태코드 제거). */
638
+ function porcelainPath(line: string): string {
639
+ return line.slice(3).trim().replace(/^"|"$/g, '');
640
+ }
641
+
642
+ /**
643
+ * run 의 산출물 카드 목록을 계산(주문형, A4). 병합 순서:
644
+ * (a) worktree 의 .coxpit/outputs.json 매니페스트(있으면) — declared 딜리버러블
645
+ * (b) git status --porcelain → 확장자 분류(.md=doc·.html=page·이미지=file·그 외=code 집계 1장)
646
+ * (c) result 이벤트의 answer 텍스트
647
+ * 각 카드: required = type ∈ task.outputs. declared 인데 산출물 없으면 present:false 플레이스홀더.
648
+ * worktree 소멸 → doc/page 는 loadRunDocs 스냅샷 폴백, code/file 은 unavailable, answer 는 이벤트.
649
+ */
650
+ export async function computeRunOutputs(runId: number): Promise<RunOutputCard[]> {
651
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
652
+ const run = rr[0];
653
+ if (!run) return [];
654
+ const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
655
+ const task = tr[0];
656
+ const declared = parseOutputs(task?.outputs);
657
+ const declaredSet = new Set<OutputType>(declared);
658
+
659
+ const ctx = await loadContext(runId);
660
+ const wt = run.worktreePath;
661
+ // worktree 가 실제로 머신에 살아있는가(정리 후에는 스냅샷 폴백).
662
+ let wtAlive = false;
663
+ if (ctx && wt) {
664
+ const t = await runShellOn(ctx.machine, `test -d ${shq(wt)} && echo yes`, 8000).catch(() => ({ stdout: '' }));
665
+ wtAlive = (t.stdout || '').includes('yes');
666
+ }
667
+
668
+ const cards: RunOutputCard[] = [];
669
+ const present = new Set<OutputType>();
670
+ const codeFiles: string[] = [];
671
+ const fileCards: RunOutputCard[] = [];
672
+ const docByPath = new Map<string, RunOutputCard>();
673
+
674
+ // (c) answer — 이벤트에서(worktree 생사 무관하게 항상 회수 가능)
675
+ const answer = await runAnswerText(run);
676
+ if (answer) {
677
+ present.add('answer');
678
+ cards.push({ type: 'answer', title: 'Final answer', required: declaredSet.has('answer'), present: true, meta: 'from the run\'s final message' });
679
+ }
680
+
681
+ // (a) 매니페스트 — path→title 힌트로 파생 카드 제목을 예쁘게(있으면). 없으면 git 분류로 폴백.
682
+ const manifestTitle = new Map<string, string>();
683
+ if (wtAlive && ctx && wt) {
684
+ const mf = await runShellOn(ctx.machine, `head -c 200000 ${shq(ppath.join(wt, '.coxpit/outputs.json'))} 2>/dev/null`, 8000)
685
+ .catch(() => ({ ok: false, stdout: '' }));
686
+ if (mf.ok && mf.stdout.trim()) {
687
+ try {
688
+ const arr = JSON.parse(mf.stdout) as unknown;
689
+ for (const it of (Array.isArray(arr) ? arr : []) as OutputsManifestItem[]) {
690
+ if (it && typeof it === 'object' && typeof it.path === 'string' && typeof it.title === 'string') {
691
+ manifestTitle.set(it.path, it.title);
692
+ }
693
+ }
694
+ } catch { /* 매니페스트 깨짐 — git 분류로 폴백 */ }
695
+ }
696
+ }
697
+
698
+ if (wtAlive && ctx && wt) {
699
+ // (b) git status --porcelain 분류
700
+ const ls = await runShellOn(ctx.machine, `git -C ${shq(wt)} status --porcelain`, 15000).catch(() => ({ ok: false, stdout: '' }));
701
+ if (ls.ok) {
702
+ const paths = ls.stdout.split('\n').map(porcelainPath).filter(Boolean);
703
+ for (const p of paths) {
704
+ if (p.startsWith('.coxpit/')) continue; // 오케스트레이션 파일은 산출물 아님
705
+ const t = extType(p);
706
+ if (t === 'doc' || t === 'page') {
707
+ present.add(t);
708
+ docByPath.set(p, { type: t, title: manifestTitle.get(p) || p, path: p, required: declaredSet.has(t), present: true, meta: t === 'doc' ? 'rendered markdown' : 'live HTML page' });
709
+ } else if (t === 'file') {
710
+ present.add('file');
711
+ fileCards.push({ type: 'file', title: manifestTitle.get(p) || p, path: p, required: declaredSet.has('file'), present: true, meta: 'file preview' });
712
+ } else {
713
+ codeFiles.push(p);
714
+ }
715
+ }
716
+ }
717
+ } else {
718
+ // worktree 소멸 — doc/page 는 스냅샷 폴백, code/file 은 unavailable.
719
+ const snap = await loadRunDocs(runId);
720
+ for (const d of snap.docs) {
721
+ const t: OutputType = d.kind === 'html' ? 'page' : 'doc';
722
+ present.add(t);
723
+ docByPath.set(d.path, { type: t, title: manifestTitle.get(d.path) || d.path, path: d.path, required: declaredSet.has(t), present: true, meta: 'worktree cleaned — snapshot only' });
724
+ }
725
+ if (run.filesChanged > 0) {
726
+ // 변경은 있었으나 worktree 가 사라져 code/file diff 를 못 준다.
727
+ if (declaredSet.has('code')) cards.push({ type: 'code', title: 'Code changes', required: true, present: false, meta: 'worktree cleaned — diff unavailable' });
728
+ if (declaredSet.has('file')) cards.push({ type: 'file', title: 'File', required: true, present: false, meta: 'worktree cleaned — file unavailable' });
729
+ }
730
+ }
731
+
732
+ // doc/page 카드 편입(경로 순)
733
+ for (const c of docByPath.values()) cards.push(c);
734
+ // file 카드 편입
735
+ for (const c of fileCards) cards.push(c);
736
+ // code — 여러 파일을 한 장의 diff 카드로 집계(worktree 라이브일 때만 생성됨)
737
+ if (codeFiles.length) {
738
+ present.add('code');
739
+ cards.push({
740
+ type: 'code',
741
+ title: 'Code changes',
742
+ required: declaredSet.has('code'),
743
+ present: true,
744
+ meta: `${codeFiles.length} file(s) — colored diff`,
745
+ });
746
+ }
747
+
748
+ // declared 인데 산출물이 없으면 ⚠ present:false 플레이스홀더(soft policy).
749
+ // future: strict mode — auto-steer "produce the missing <type>"
750
+ for (const t of declared) {
751
+ if (present.has(t)) continue;
752
+ if (cards.some((c) => c.type === t && c.present === false)) continue; // worktree-gone 폴백이 이미 추가
753
+ cards.push({ type: t, title: `Missing ${t}`, required: true, present: false, meta: '산출물 미충족' });
754
+ }
755
+
756
+ return cards;
757
+ }
758
+
552
759
  /**
553
760
  * base 동기화 — 오래 사는 세션의 worktree 에 base 브랜치 최신을 머지한다.
554
761
  * 충돌 시 자동 abort — 그땐 steer 로 에이전트에게 머지를 맡기라고 안내.
@@ -668,6 +875,25 @@ export async function openWorkbench(repoId: number, title: string): Promise<{
668
875
  return { ok: true, detail: 'workbench open', taskId: task.id, runId };
669
876
  }
670
877
 
878
+ /**
879
+ * 그룹에 속한 태스크 1개를 만들고 run 1개를 발사한다(공용 helper).
880
+ * planFanout(plan 형제) 과 /api/groups/:id/spawn(+New attempt) 이 공유하는
881
+ * "태스크 생성(groupId 각인) → run 생성 → 브로드캐스트 → launchRun" 몸통.
882
+ */
883
+ export async function launchGroupTask(
884
+ groupId: number, repoId: number, title: string, prompt: string, real: boolean,
885
+ ): Promise<{ id: number; title: string; runId: number }> {
886
+ const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
887
+ const machineId = rp[0]!.machineId;
888
+ const tIns = await db.insert(tasks).values({ repoId, title: title.slice(0, 140), prompt, groupId }).returning();
889
+ const task = tIns[0]!;
890
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending' }).returning();
891
+ const run = rIns[0]!;
892
+ broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
893
+ void launchRun(run.id, real);
894
+ return { id: task.id, title: task.title, runId: run.id };
895
+ }
896
+
671
897
  /**
672
898
  * Plan fan-out — 스웜의 입구. 목표 하나를 받아 플래너 에이전트가 repo 를 읽고
673
899
  * 독립 실행 가능한 하위 태스크들로 분해 → 각 태스크를 count 1 로 자동 발사한다.
@@ -728,13 +954,7 @@ export async function planFanout(repoId: number, goal: string, real: boolean): P
728
954
 
729
955
  const created: Array<{ id: number; title: string; runId: number }> = [];
730
956
  for (const t of plan) {
731
- const tIns = await db.insert(tasks).values({ repoId, title: t.title, prompt: t.prompt, groupId }).returning();
732
- const task = tIns[0]!;
733
- const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'claude-code', status: 'pending' }).returning();
734
- const run = rIns[0]!;
735
- broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
736
- void launchRun(run.id, real);
737
- created.push({ id: task.id, title: t.title, runId: run.id });
957
+ created.push(await launchGroupTask(groupId, repoId, t.title, t.prompt, real));
738
958
  }
739
959
  return { ok: true, detail: `${created.length} task(s) launched`, tasks: created };
740
960
  }
@@ -795,6 +1015,117 @@ export async function reviewTask(taskId: number, real: boolean): Promise<{ ok: b
795
1015
  }
796
1016
  }
797
1017
 
1018
+ /**
1019
+ * Ask 코디네이터 — 읽기 전용, 재개 가능한 그룹 스코프 Q&A.
1020
+ * 그룹의 형제 run 들에서 {title,status,agent,filesChanged} + 정착 run 의 bounded diff 요약을
1021
+ * 모아 컨텍스트로 주고, 질문에 답만 한다. worktree 를 열지도, run 을 발사하지도, 파일을 쓰지도,
1022
+ * steer 하지도 않는다 — getRunDiff(읽기)와 텍스트 반환뿐. (reviewTask 를 대화형·재개형으로 변형)
1023
+ *
1024
+ * 세션: 첫 호출은 1회용(`bin -p <prompt> --output-format json`)으로 session_id 를 캡처해
1025
+ * task_groups.coord_session_id 에 저장. 이후 호출은 provider.resumeCmd 로 진짜 대화를 잇는다.
1026
+ * 드라이(real=false / COXPIT_AGENT_REAL off)는 결정적 mock + 합성 세션 id 반환(크레딧 0, e2e 안전).
1027
+ */
1028
+ export async function askGroupCoordinator(
1029
+ groupId: number, message: string, real: boolean,
1030
+ ): Promise<{ ok: boolean; detail: string; answer?: string }> {
1031
+ const gr = await db.select().from(taskGroups).where(eq(taskGroups.id, groupId)).limit(1);
1032
+ const group = gr[0];
1033
+ if (!group) return { ok: false, detail: 'group not found' };
1034
+ const msg = message.trim();
1035
+ if (!msg) return { ok: false, detail: 'empty message' };
1036
+
1037
+ // 그룹의 형제 run 들(태스크 조인) — bounded 컨텍스트만.
1038
+ const gts = await db.select().from(tasks).where(eq(tasks.groupId, groupId));
1039
+ const rows: Array<{ run: typeof agentRuns.$inferSelect; task: typeof tasks.$inferSelect }> = [];
1040
+ for (const t of gts) {
1041
+ const trs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, t.id));
1042
+ for (const run of trs) rows.push({ run, task: t });
1043
+ }
1044
+ rows.sort((a, b) => a.run.id - b.run.id);
1045
+
1046
+ // repo/machine 은 그룹의 아무 태스크에서 상속(형제는 같은 repo 공유). read-only 는 repo 본체에서.
1047
+ const anyTask = rows[0]?.task ?? gts[0];
1048
+ let machine: MachineTarget | null = null;
1049
+ let repoPath = '';
1050
+ if (anyTask) {
1051
+ const rp = await db.select().from(repos).where(eq(repos.id, anyTask.repoId)).limit(1);
1052
+ const repo = rp[0];
1053
+ if (repo) {
1054
+ repoPath = repo.path;
1055
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
1056
+ const m = mr[0];
1057
+ if (m) machine = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
1058
+ }
1059
+ }
1060
+
1061
+ // bounded 컨텍스트: run 요약 + 정착 run 의 diff 요약(각 ~1500자, 합 ~12k 상한).
1062
+ const SETTLED = ['done', 'failed', 'stopped', 'merged'];
1063
+ const sections: string[] = [];
1064
+ let budget = 12000;
1065
+ for (const { run, task } of rows) {
1066
+ let sec = `### run r${run.id} — ${task.title.slice(0, 80)}\n`
1067
+ + `status: ${run.status} · agent: ${run.agent} · files changed: ${run.filesChanged}`;
1068
+ if (SETTLED.includes(run.status) && budget > 0) {
1069
+ const d = await getRunDiff(run.id).catch(() => ({ ok: false, diff: '', stat: '' }));
1070
+ const raw = (d.ok ? (d.diff || d.stat || '(no changes)') : '(worktree gone — diff unavailable)');
1071
+ const cap = Math.min(1500, Math.max(0, budget));
1072
+ const clip = raw.slice(0, cap);
1073
+ budget -= clip.length;
1074
+ sec += `\nDiff summary:\n\`\`\`diff\n${clip}\n\`\`\``;
1075
+ }
1076
+ sections.push(sec);
1077
+ }
1078
+
1079
+ const preamble =
1080
+ `You are a READ-ONLY coordinator for a goal with these parallel attempts. `
1081
+ + `Answer the question about their state and diffs. Do NOT propose running commands, `
1082
+ + `do NOT modify files, do NOT suggest editing anything — you can only observe and explain.`;
1083
+ const context = `Goal: ${group.title}\n\n${sections.join('\n\n') || '(no runs yet)'}`;
1084
+
1085
+ // 드라이: 결정적 mock 답변 + 합성 세션 id(첫 호출 시 저장, 이후 재사용). e2e 크레딧 0.
1086
+ if (!real) {
1087
+ const done = rows.filter((r) => SETTLED.includes(r.run.status)).length;
1088
+ const running = rows.filter((r) => r.run.status === 'running').length;
1089
+ const answer = `[dry coordinator] ${rows.length} attempt(s) · ${done} settled · ${running} running.\n`
1090
+ + `Q: ${msg.slice(0, 120)}\n`
1091
+ + `(rehearsal answer — read-only; run with Real agent for a substantive reply.)`;
1092
+ if (!group.coordSessionId) {
1093
+ const synth = 'dry-coord-' + randomBytes(6).toString('hex');
1094
+ await db.update(taskGroups).set({ coordSessionId: synth }).where(eq(taskGroups.id, groupId));
1095
+ }
1096
+ return { ok: true, detail: 'rehearsal answer', answer };
1097
+ }
1098
+
1099
+ if (!machine || !repoPath) return { ok: false, detail: 'group has no repo to run the coordinator from' };
1100
+
1101
+ // 첫 호출 = 1회용(session_id 캡처), 이후 = resume(대화 이어가기). 둘 다 파일 미변경 read-only.
1102
+ const provider = getProvider('claude-code');
1103
+ let cmd: string;
1104
+ const resuming = !!group.coordSessionId;
1105
+ if (resuming) {
1106
+ // resume 은 stream-json 을 내지만 여기선 마지막 result 만 필요 — json 으로 강제 재래핑 불가하므로
1107
+ // 세션 id 는 이미 있으니 resumeCmd(대화)로 잇고, 최종 텍스트는 result 라인에서 추출한다.
1108
+ cmd = `cd ${shq(repoPath)} && ${provider.resumeCmd(group.coordSessionId, `${preamble}\n\n${context}\n\nQuestion: ${msg}`)} --output-format json`;
1109
+ } else {
1110
+ const oneShot = `${preamble}\n\n${context}\n\nQuestion: ${msg}`;
1111
+ cmd = `cd ${shq(repoPath)} && ${config.agent.bin} -p ${shq(oneShot)} --output-format json`;
1112
+ }
1113
+ const r = await runShellOn(machine, cmd, 300000);
1114
+ if (!r.ok) return { ok: false, detail: 'coordinator failed: ' + (r.stderr || r.stdout).trim().slice(0, 300) };
1115
+ try {
1116
+ const envelope = JSON.parse(r.stdout.trim()) as { result?: string; session_id?: string };
1117
+ const answer = (envelope.result ?? '').trim();
1118
+ if (!answer) throw new Error('empty answer');
1119
+ // 첫 호출에서만 세션 각인(이후엔 유지). resume 응답도 같은 세션이라 덮어써도 무해.
1120
+ if (typeof envelope.session_id === 'string' && envelope.session_id && envelope.session_id !== group.coordSessionId) {
1121
+ await db.update(taskGroups).set({ coordSessionId: envelope.session_id }).where(eq(taskGroups.id, groupId));
1122
+ }
1123
+ return { ok: true, detail: resuming ? 'resumed' : 'answered', answer };
1124
+ } catch (e) {
1125
+ return { ok: false, detail: 'could not parse coordinator answer: ' + String(e).slice(0, 200) };
1126
+ }
1127
+ }
1128
+
798
1129
  export interface IntegrateResult {
799
1130
  runId: number;
800
1131
  status: 'merged' | 'conflict' | 'skipped';
@@ -980,5 +1311,120 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
980
1311
  ` ; git -C ${shq(ctx.repoPath)} branch -D ${shq(run.branch)} 2>&1 || true`,
981
1312
  20000,
982
1313
  );
1314
+ // 세션·worktree 는 이제 없다 — 스테일 포인터를 비운다. 안 그러면 getRunTermInfo 가
1315
+ // 죽은 tmux 이름을 계속 돌려줘 /ws/term/:id 가 없는 세션에 attach 를 시도한다(closed task 버그).
1316
+ await setRun(runId, { worktreePath: '', tmuxWindow: '' });
983
1317
  return { ok: true, detail: rm.stdout.trim().slice(0, 300) };
984
1318
  }
1319
+
1320
+ // ── 고아 worktree 회수(reclaim) ────────────────────────────────
1321
+ // run worktree 는 종종 node_modules 를 품어 ~180MB 의 디스크 빚이 된다. Close 는
1322
+ // cleanupRun 으로 이미 정리하지만, 실패·에러·데몬 재시작으로 고아가 된 run 은
1323
+ // (검수용으로) worktree 를 남겨두므로 쌓인다. 이를 안전하게 되찾는 길.
1324
+ //
1325
+ // 안전 규칙(핵심): running/preparing/pending/'done' run 은 절대 대상 아님 —
1326
+ // 활성 작업이거나(진행 중), 성공했지만 아직 머지 안 됐을 수 있는 작업이므로.
1327
+
1328
+ /** 회수 대상 판정용 안전 상태 집합 — task 가 closed 이거나 run 상태가 이 중 하나. */
1329
+ const RECLAIM_STATUSES = new Set(['failed', 'error', 'stopped']);
1330
+
1331
+ export interface ReclaimableWorktree {
1332
+ runId: number;
1333
+ path: string;
1334
+ branch: string;
1335
+ taskId: number;
1336
+ reason: string; // 'task closed' | 'failed' | 'error' | 'stopped'
1337
+ exists: boolean; // worktree dir 가 아직 디스크에 있나(false = 이미 수동 삭제됨)
1338
+ sizeKb?: number; // best-effort du -sk (실패 시 생략)
1339
+ }
1340
+
1341
+ /**
1342
+ * 안전하게 회수 가능한 worktree 목록. worktreePath 가 비어있지 않은 모든 run 중
1343
+ * (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped} 인 것만.
1344
+ * running/preparing/pending/'done'/'open'/'merged' 는 절대 포함하지 않는다
1345
+ * (활성 또는 성공-미머지 가능성). exists=디스크 잔존 여부, sizeKb=best-effort du.
1346
+ */
1347
+ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]> {
1348
+ const allRuns = (await db.select().from(agentRuns)).filter((r) => !!r.worktreePath);
1349
+ // task 상태 룩업(closed 판정용)
1350
+ const taskById = new Map<number, typeof tasks.$inferSelect>();
1351
+ for (const t of await db.select().from(tasks)) taskById.set(t.id, t);
1352
+
1353
+ const out: ReclaimableWorktree[] = [];
1354
+ for (const run of allRuns) {
1355
+ if (isRunLive(run.id)) continue; // 라이브 자식 보유 = 실행 중, 절대 건드리지 않음
1356
+ const task = taskById.get(run.taskId);
1357
+ const taskClosed = task?.status === 'closed';
1358
+ const statusReclaim = RECLAIM_STATUSES.has(run.status);
1359
+ if (!taskClosed && !statusReclaim) continue; // done/open/merged/running/preparing/pending 제외
1360
+ const reason = taskClosed ? 'task closed' : run.status;
1361
+
1362
+ // worktree 잔존 여부 + best-effort 사이즈(로컬만 정확; 원격은 machine 경유).
1363
+ const ctx = await loadContext(run.id).catch(() => null);
1364
+ let exists = false;
1365
+ let sizeKb: number | undefined;
1366
+ if (ctx) {
1367
+ const chk = await runShellOn(ctx.machine, `test -d ${shq(run.worktreePath)} && echo yes`, 8000)
1368
+ .catch(() => ({ stdout: '' as string }));
1369
+ exists = (chk.stdout || '').includes('yes');
1370
+ if (exists) {
1371
+ // du 는 큰 트리에서 느릴 수 있어 타임아웃으로 가드 — 실패해도 목록은 낸다.
1372
+ const du = await runShellOn(ctx.machine, `du -sk ${shq(run.worktreePath)} 2>/dev/null | cut -f1`, 8000)
1373
+ .catch(() => ({ ok: false as boolean, stdout: '' as string }));
1374
+ const n = parseInt((du.stdout || '').trim(), 10);
1375
+ if (du.ok && Number.isFinite(n) && n > 0) sizeKb = n;
1376
+ }
1377
+ }
1378
+ out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb });
1379
+ }
1380
+ return out;
1381
+ }
1382
+
1383
+ /**
1384
+ * 회수 실행 — 대상 run(전체 또는 runIds 부분집합)의 worktree 를 되찾는다.
1385
+ * dir 가 아직 있으면 cleanupRun 재사용(tmux kill + git worktree remove + branch -D + 포인터 blank).
1386
+ * dir 가 이미 수동 삭제됐으면 git worktree prune + branch -D + DB 포인터 blank 만.
1387
+ * 마지막에 영향받은 repo 마다 git worktree prune 1회(스테일 메타데이터 정리).
1388
+ * 멱등 — 다시 돌려도 안전(이미 회수된 run 은 worktreePath 가 비어 목록에서 빠짐).
1389
+ */
1390
+ export async function pruneWorktrees(runIds?: number[]): Promise<{
1391
+ removed: Array<{ runId: number; detail: string }>; count: number;
1392
+ }> {
1393
+ const reclaimable = await listReclaimableWorktrees();
1394
+ const want = runIds && runIds.length ? new Set(runIds) : null;
1395
+ const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable;
1396
+
1397
+ const removed: Array<{ runId: number; detail: string }> = [];
1398
+ const affectedRepoPaths = new Map<string, MachineTarget>(); // repoPath -> machine (prune 대상)
1399
+
1400
+ for (const t of targets) {
1401
+ const ctx = await loadContext(t.runId).catch(() => null);
1402
+ if (ctx) affectedRepoPaths.set(ctx.repoPath, ctx.machine);
1403
+ if (t.exists) {
1404
+ // dir 잔존 — cleanupRun 이 tmux·worktree remove --force·branch -D·포인터 blank 를 모두 처리.
1405
+ const res = await cleanupRun(t.runId).catch((e) => ({ ok: false, detail: String(e).slice(0, 200) }));
1406
+ removed.push({ runId: t.runId, detail: res.detail || (res.ok ? 'removed' : 'cleanup failed') });
1407
+ } else if (ctx) {
1408
+ // dir 는 이미 수동 삭제 — git 이 여전히 스테일 worktree 를 물고 있다. prune + branch -D + 포인터 blank.
1409
+ const r = await runShellOn(
1410
+ ctx.machine,
1411
+ `git -C ${shq(ctx.repoPath)} worktree prune 2>&1` +
1412
+ `${t.branch ? ` ; git -C ${shq(ctx.repoPath)} branch -D ${shq(t.branch)} 2>&1 || true` : ''}`,
1413
+ 20000,
1414
+ ).catch(() => ({ stdout: '' as string }));
1415
+ await setRun(t.runId, { worktreePath: '', tmuxWindow: '' });
1416
+ removed.push({ runId: t.runId, detail: (r.stdout || '').trim().slice(0, 200) || 'dir already gone — pruned metadata' });
1417
+ } else {
1418
+ // context 없음(repo/machine 소실) — 최소한 DB 포인터라도 비운다.
1419
+ await setRun(t.runId, { worktreePath: '', tmuxWindow: '' });
1420
+ removed.push({ runId: t.runId, detail: 'context missing — cleared DB pointer' });
1421
+ }
1422
+ }
1423
+
1424
+ // repo 마다 worktree prune 1회 — 스테일 메타데이터를 확실히 청소(멱등).
1425
+ for (const [repoPath, machine] of affectedRepoPaths) {
1426
+ await runShellOn(machine, `git -C ${shq(repoPath)} worktree prune 2>/dev/null || true`, 15000).catch(() => { /* best-effort */ });
1427
+ }
1428
+
1429
+ return { removed, count: removed.length };
1430
+ }