coxpit 5.27.6 → 6.1.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/README.md +8 -7
- package/package.json +1 -1
- package/src/agentstate.ts +277 -0
- package/src/authkey.ts +30 -0
- package/src/board.ts +57 -17
- package/src/cockpit.ts +1692 -49
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +4 -0
- package/src/files.ts +6 -0
- package/src/orchestrator.ts +261 -28
- package/src/procscan.ts +241 -0
- package/src/server.ts +254 -24
- package/src/workdoc.ts +65 -0
package/src/db/index.ts
CHANGED
|
@@ -113,4 +113,6 @@ export async function ensureSchema(): Promise<void> {
|
|
|
113
113
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_status TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
114
114
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN verify_output TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
115
115
|
try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
|
|
116
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN title TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
117
|
+
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
116
118
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -82,8 +82,12 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
82
82
|
taskId: integer('task_id').notNull(),
|
|
83
83
|
machineId: integer('machine_id').notNull(),
|
|
84
84
|
agent: text('agent').notNull().default('claude-code'),
|
|
85
|
+
title: text('title').notNull().default(''), // v6.0 — 작업 안에서의 역할 이름(예: 구현·기타). 빈값 = 프로바이더 이름/main 으로 표시
|
|
85
86
|
worktreePath: text('worktree_path').notNull().default(''),
|
|
86
87
|
branch: text('branch').notNull().default(''),
|
|
88
|
+
// v6.0 Part P — 격리 없이 repo 체크아웃에서 그대로 도는 에이전트(worktree·브랜치 없음).
|
|
89
|
+
// 결과물은 루트 세션 마커와 같다(worktree_path=repo.path, branch=''); 이 플래그는 "그렇게 띄워라"는 의도다.
|
|
90
|
+
inPlace: integer('in_place', { mode: 'boolean' }).notNull().default(false),
|
|
87
91
|
tmuxWindow: text('tmux_window').notNull().default(''),
|
|
88
92
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
89
93
|
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
package/src/files.ts
CHANGED
|
@@ -28,6 +28,12 @@ function startDir(root: string): string {
|
|
|
28
28
|
return (HOME === root || HOME.startsWith(root + '/')) ? HOME : root;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// 어떤 절대경로가 지금의 뷰어 루트 안인가 — 호출자가 "평소 파일 뷰어로 열면 되는지"를
|
|
32
|
+
// 미리 물어보는 창구(v6.0 W2: WORK.md 는 ~/.coxpit 아래라 기본 루트(홈) 안이다).
|
|
33
|
+
export function withinFilesRoot(p: string): boolean {
|
|
34
|
+
return withinRoot(presolve(p), currentRoot());
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
export type FileKind = 'md' | 'html' | 'pdf' | 'image' | 'text' | 'binary';
|
|
32
38
|
|
|
33
39
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']);
|
package/src/orchestrator.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnap
|
|
|
12
12
|
import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
|
|
13
13
|
import { broadcast } from './hub';
|
|
14
14
|
import { getProvider, type Provider } from './providers';
|
|
15
|
+
import { workContextBlock, removeWorkDoc } from './workdoc';
|
|
15
16
|
|
|
16
17
|
// ── 산출물 계약(deliverable contract) ─────────────────────────
|
|
17
18
|
/** 산출물 타입 5종 — 태스크가 선언할 수 있는 계약 항목. */
|
|
@@ -223,6 +224,35 @@ export function isRunLive(runId: number): boolean {
|
|
|
223
224
|
return liveChildren.has(runId) || adoptedRuns.has(runId);
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
/**
|
|
228
|
+
* 발사 창 — launchRun 이 착수했지만 아직 자식 프로세스가 안 생긴 구간.
|
|
229
|
+
* launchRun 의 첫 문장이라 요청 핸들러가 응답을 돌려주기 **전에** 동기적으로 들어간다.
|
|
230
|
+
* 메모리에만 있어 데몬이 죽으면 같이 사라진다(= 재시작 후 유령이 체크아웃을 물고 있는 일이 없다).
|
|
231
|
+
*/
|
|
232
|
+
const launching = new Set<number>();
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* v6.0 P3 — 이 repo 체크아웃에서 지금 일하고 있는 in-place **에이전트** run 이 있으면 그 id.
|
|
236
|
+
* 한 체크아웃에 에이전트 둘은 서로의 편집을 덮어쓴다 → 발사 전에 이걸로 막는다(409).
|
|
237
|
+
*
|
|
238
|
+
* 살아있음 = 자식 보유/재-adopt(isRunLive) **또는** 발사 창(launching). 후자가 없으면
|
|
239
|
+
* 연달아 들어온 두 요청이 둘 다 통과한다 — launchRun 은 fire-and-forget 이라
|
|
240
|
+
* 두 번째 검사 시점엔 첫 run 의 자식이 아직 안 생겼기 때문.
|
|
241
|
+
* 손 터미널(agent='session'·'workbench')은 세지 않는다 — 사람이 자기 체크아웃에
|
|
242
|
+
* 터미널을 몇 개 열든 그건 사람의 선택이다(D3).
|
|
243
|
+
*/
|
|
244
|
+
export async function liveInPlaceRun(repoId: number): Promise<number | null> {
|
|
245
|
+
const repoTasks = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.repoId, repoId));
|
|
246
|
+
if (!repoTasks.length) return null;
|
|
247
|
+
const ids = repoTasks.map((t) => t.id);
|
|
248
|
+
const rs = (await db.select().from(agentRuns).where(inArray(agentRuns.taskId, ids)))
|
|
249
|
+
.filter((r) => r.inPlace && r.agent !== 'session' && r.agent !== 'workbench');
|
|
250
|
+
for (const r of rs) {
|
|
251
|
+
if (isRunLive(r.id) || launching.has(r.id)) return r.id;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
226
256
|
/** 에이전트 프롬프트에 붙는 능력 고지 — 독립 하위작업을 병렬 서브런으로 뺄 수 있다.
|
|
227
257
|
* 파일 기반: 기본 권한(claude acceptEdits · codex workspace-write)이 네트워크를 막아도
|
|
228
258
|
* 파일 쓰기는 되므로, spawn 요청을 워크트리의 .coxpit/spawn.json 으로 받는다. */
|
|
@@ -362,6 +392,7 @@ function remoteKillScript(worktreePath: string): string {
|
|
|
362
392
|
|
|
363
393
|
interface RunContext {
|
|
364
394
|
runId: number;
|
|
395
|
+
taskId: number;
|
|
365
396
|
machine: MachineTarget;
|
|
366
397
|
machineId: number;
|
|
367
398
|
repoId: number;
|
|
@@ -371,6 +402,7 @@ interface RunContext {
|
|
|
371
402
|
real: boolean;
|
|
372
403
|
agent: string;
|
|
373
404
|
model: string;
|
|
405
|
+
inPlace: boolean; // v6.0 P1 — 격리 worktree 없이 repo 체크아웃에서 그대로 돈다
|
|
374
406
|
}
|
|
375
407
|
|
|
376
408
|
async function loadContext(runId: number): Promise<RunContext | null> {
|
|
@@ -405,8 +437,15 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
405
437
|
const declared = parseOutputs(task.outputs);
|
|
406
438
|
if (declared.length) prompt += deliverablesNote(declared);
|
|
407
439
|
|
|
440
|
+
// v6.0 W3 — 작업의 공유 컨텍스트(WORK.md). 격리된 run 은 과거에서 갈라져 나오지만,
|
|
441
|
+
// 여기 적힌 결정은 이 작업 아래 모든 세션에 따라붙는다(파일 컨텍스트는 Part P 의 몫,
|
|
442
|
+
// 결정 컨텍스트는 이쪽 몫). 디자인 캡처·산출물 계약과 같은 시임이라 발사 경로 전부가 덮인다.
|
|
443
|
+
// ⚠️ 편집은 **다음 발사·steer** 부터 닿는다 — 돌고 있는 턴에 끼어드는 마법은 없다.
|
|
444
|
+
prompt += workContextBlock(task.id);
|
|
445
|
+
|
|
408
446
|
return {
|
|
409
447
|
runId,
|
|
448
|
+
taskId: task.id,
|
|
410
449
|
machine: { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser },
|
|
411
450
|
machineId: m.id,
|
|
412
451
|
repoId: repo.id,
|
|
@@ -416,6 +455,7 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
416
455
|
real: config.agent.real,
|
|
417
456
|
agent: run.agent,
|
|
418
457
|
model: run.model,
|
|
458
|
+
inPlace: run.inPlace,
|
|
419
459
|
};
|
|
420
460
|
}
|
|
421
461
|
|
|
@@ -423,30 +463,41 @@ async function loadContext(runId: number): Promise<RunContext | null> {
|
|
|
423
463
|
* 한 AgentRun 실행: worktree 생성 → tmux 창(best-effort) → 에이전트 spawn →
|
|
424
464
|
* stdout 라인 파싱하며 이벤트 적재 → 종료 시 files_changed 집계 + status 전이.
|
|
425
465
|
* fire-and-forget. 실패는 status='error' 로 봉인.
|
|
466
|
+
*
|
|
467
|
+
* v6.0 P1 — in-place run 은 1) 을 통째로 건너뛴다: worktree 도 브랜치도 만들지 않고
|
|
468
|
+
* repo 체크아웃(ctx.repoPath)에서 그대로 돈다. 남는 자국은 **루트 세션 마커 그대로**
|
|
469
|
+
* (branch='' · worktreePath=repo.path) 라 merge/PR/sync/cleanup 은 이미 있는 판정이
|
|
470
|
+
* 알아서 비껴간다 — 새 개념이 아니라 이미 있던 표식의 재사용이다.
|
|
426
471
|
*/
|
|
427
472
|
export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
473
|
+
// 발사 창 진입 — 이 한 줄은 호출 즉시(첫 await 전에) 실행되므로, 뒤이어 들어온
|
|
474
|
+
// in-place 요청이 아직 자식이 없는 이 run 을 "없는 것"으로 볼 수 없다(P3 가드).
|
|
475
|
+
launching.add(runId);
|
|
428
476
|
const ctx = await loadContext(runId);
|
|
429
|
-
if (!ctx) return;
|
|
477
|
+
if (!ctx) { launching.delete(runId); return; }
|
|
430
478
|
const useReal = real ?? ctx.real;
|
|
431
479
|
|
|
432
|
-
const
|
|
480
|
+
const inPlace = ctx.inPlace;
|
|
481
|
+
const branch = inPlace ? '' : `coxpit/r${runId}`;
|
|
433
482
|
const wtParent = ppath.join(ppath.dirname(ctx.repoPath), '.coxpit-worktrees');
|
|
434
|
-
const wtPath = ppath.join(wtParent, `r${runId}`);
|
|
483
|
+
const wtPath = inPlace ? ctx.repoPath : ppath.join(wtParent, `r${runId}`);
|
|
435
484
|
const session = `coxpit-r${runId}`;
|
|
436
485
|
|
|
437
486
|
try {
|
|
438
487
|
await setRun(runId, { status: 'preparing', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
|
|
439
488
|
|
|
440
|
-
// 1) worktree 생성(격리 브랜치)
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
489
|
+
// 1) worktree 생성(격리 브랜치) — in-place 는 건너뛴다(격리가 없는 것이 요점).
|
|
490
|
+
if (!inPlace) {
|
|
491
|
+
const prep = await runShellOn(
|
|
492
|
+
ctx.machine,
|
|
493
|
+
`mkdir -p ${shq(wtParent)} && git -C ${shq(ctx.repoPath)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(ctx.baseBranch)}`,
|
|
494
|
+
20000,
|
|
495
|
+
);
|
|
496
|
+
if (!prep.ok) {
|
|
497
|
+
await recordEvent(runId, 'error', (prep.stderr || prep.stdout).trim().slice(0, 500));
|
|
498
|
+
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'worktree add failed' });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
450
501
|
}
|
|
451
502
|
|
|
452
503
|
// 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치).
|
|
@@ -456,7 +507,7 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
456
507
|
`export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d${runEnv} -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
|
|
457
508
|
|
|
458
509
|
await setRun(runId, { status: 'running' });
|
|
459
|
-
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
|
|
510
|
+
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal, ...(inPlace ? { inPlace: true } : {}) }));
|
|
460
511
|
|
|
461
512
|
// 3) 에이전트 spawn(스트리밍)
|
|
462
513
|
// 원격은 ssh 채널이 죽어도 프로세스가 남을 수 있어 pid 파일을 남긴다(stop 시 원격 kill).
|
|
@@ -490,6 +541,8 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
490
541
|
} catch (e) {
|
|
491
542
|
await recordEvent(runId, 'error', String(e).slice(0, 500));
|
|
492
543
|
await setRun(runId, { status: 'error', endedAt: new Date(), exitSummary: 'orchestrator error' });
|
|
544
|
+
} finally {
|
|
545
|
+
launching.delete(runId); // 발사 창 해제 — 여기까지 오면 살아있음 판정은 liveChildren 이 이어받는다
|
|
493
546
|
}
|
|
494
547
|
}
|
|
495
548
|
|
|
@@ -645,9 +698,11 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
|
|
|
645
698
|
await recordEvent(runId, mode === 'ask' ? 'ask' : 'steer', message.slice(0, 2000));
|
|
646
699
|
|
|
647
700
|
// Ask 모드 — 세션에 질문만: 파일 수정 없이 답변만 하도록 래핑
|
|
648
|
-
|
|
701
|
+
// 뒤에 붙는 WORK CONTEXT(W3): 작업 중간에 steer 를 받는 에이전트도 그동안 적힌 결정을 본다.
|
|
702
|
+
// 여기서도 편집은 **이 steer 부터** 닿는다(이미 돌고 있던 턴은 건드리지 않는다).
|
|
703
|
+
const finalMessage = (mode === 'ask'
|
|
649
704
|
? `Question about your work in this session (do NOT modify any files, do NOT run write commands — answer concisely):\n${message}`
|
|
650
|
-
: message;
|
|
705
|
+
: message) + workContextBlock(ctx.taskId);
|
|
651
706
|
|
|
652
707
|
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
653
708
|
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
@@ -740,6 +795,25 @@ export async function getScrollback(runId: number, lines: number): Promise<{ ok:
|
|
|
740
795
|
return { ok: true, text: r.stdout };
|
|
741
796
|
}
|
|
742
797
|
|
|
798
|
+
/**
|
|
799
|
+
* 이 run 의 페인이 **지금 서 있는 폴더** (v5.28 D-fix).
|
|
800
|
+
* 터미널이 찍은 상대경로를 열려면 worktree 루트가 아니라 페인의 cwd 가 기준이어야 한다 —
|
|
801
|
+
* 모노레포 하위 패키지나 `cd` 뒤에는 루트가 답이 아니고, 그때 링크는 없는 파일을 가리켰다.
|
|
802
|
+
* 타깃은 언제나 '=' 정확 일치(coxpit-r5 가 coxpit-r50 을 집는 사고를 두 번 겪었다).
|
|
803
|
+
* display-message 가 페인 타깃으로 '=' 를 못 받는 tmux 가 있어 list-panes -s(타깃-세션) 로 한 번 더 받친다.
|
|
804
|
+
* 못 알아내면 빈 문자열 — 클라이언트는 worktree 로 폴백한다. 지어내지 않는다.
|
|
805
|
+
*/
|
|
806
|
+
export async function getRunPwd(runId: number): Promise<{ ok: boolean; pwd: string }> {
|
|
807
|
+
const info = await getRunTermInfo(runId);
|
|
808
|
+
if (!info) return { ok: false, pwd: '' };
|
|
809
|
+
const t = shq('=' + info.session);
|
|
810
|
+
const cmd = `tmux display -p -t ${t} '#{pane_current_path}' 2>/dev/null || tmux list-panes -s -t ${t} -F '#{pane_current_path}' 2>/dev/null | head -1`;
|
|
811
|
+
const r = await runShellOn(info.machine, cmd, 8000);
|
|
812
|
+
const pwd = ((r.stdout || '').split('\n')[0] || '').trim();
|
|
813
|
+
if (!r.ok || !pwd) return { ok: false, pwd: '' };
|
|
814
|
+
return { ok: true, pwd };
|
|
815
|
+
}
|
|
816
|
+
|
|
743
817
|
/**
|
|
744
818
|
* 실행 중 run 중지 — 자식 프로세스 SIGTERM. close 핸들러가 status='stopped' 로 봉인.
|
|
745
819
|
*/
|
|
@@ -1175,7 +1249,8 @@ export async function deleteSession(runId: number): Promise<{ ok: boolean; detai
|
|
|
1175
1249
|
await db.delete(agentRuns).where(eq(agentRuns.id, runId));
|
|
1176
1250
|
// 세션 task 는 run 과 1:1 — 남은 run 이 없으면 task 도 제거해 트리에서 사라지게.
|
|
1177
1251
|
const siblings = await db.select().from(agentRuns).where(eq(agentRuns.taskId, run.taskId));
|
|
1178
|
-
|
|
1252
|
+
// 작업이 사라지면 그 작업의 WORK.md 도 같이 사라진다(W1 — 정본은 task 수명에 매인다).
|
|
1253
|
+
if (task && siblings.length === 0) { await db.delete(tasks).where(eq(tasks.id, task.id)); removeWorkDoc(task.id); }
|
|
1179
1254
|
broadcast({ type: 'run', runId, deleted: true }); // 모든 콘솔이 재하이드레이트 → 행 제거
|
|
1180
1255
|
return { ok: true, detail: 'session deleted (folder preserved)' };
|
|
1181
1256
|
}
|
|
@@ -1981,33 +2056,51 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
|
|
|
1981
2056
|
// cleanupRun 으로 이미 정리하지만, 실패·에러·데몬 재시작으로 고아가 된 run 은
|
|
1982
2057
|
// (검수용으로) worktree 를 남겨두므로 쌓인다. 이를 안전하게 되찾는 길.
|
|
1983
2058
|
//
|
|
1984
|
-
// 안전 규칙(핵심): running/preparing/pending
|
|
1985
|
-
//
|
|
2059
|
+
// 안전 규칙(핵심): running/preparing/pending run 은 절대 대상 아님 — 활성 작업이므로.
|
|
2060
|
+
//
|
|
2061
|
+
// v6.0 T6b — 빚이 실제로 쌓이는 자리는 **끝났는데 머지도 닫지도 않은 run** 이었다.
|
|
2062
|
+
// 그래서 done/merged 도 목록에는 올린다. 다만 하나를 갈라 본다:
|
|
2063
|
+
// 산출물이 이미 빠져나갔나(머지됐거나 export·PR 됐나) = 지워도 되는 사본
|
|
2064
|
+
// 아직 아무 데도 없나 = 이 worktree 가 **유일한 사본** → reclaimRisk:true
|
|
2065
|
+
// 위험한 것은 **보여주되 미리 고르지 않고**, 전체 회수(runIds 없음)에서도 빠진다.
|
|
2066
|
+
// 사람이 직접 찍어 보낸 runIds 만 그 선을 넘는다.
|
|
1986
2067
|
|
|
1987
2068
|
/** 회수 대상 판정용 안전 상태 집합 — task 가 closed 이거나 run 상태가 이 중 하나. */
|
|
1988
2069
|
const RECLAIM_STATUSES = new Set(['failed', 'error', 'stopped']);
|
|
2070
|
+
/** v6.0 T6b — 정착한 성공 run. 격리 worktree 를 가진 것만 목록에 든다(위험 표시와 함께). */
|
|
2071
|
+
const RECLAIM_SETTLED_STATUSES = new Set(['done', 'merged']);
|
|
2072
|
+
/** taskCloseRisk 와 **같은** 신호를 쓰는 상태 집합 — 정착 + 변경있음 + 미탈출 = 위험. */
|
|
2073
|
+
const RISK_STATUSES = new Set(['done', 'failed', 'stopped']);
|
|
1989
2074
|
|
|
1990
2075
|
export interface ReclaimableWorktree {
|
|
1991
2076
|
runId: number;
|
|
1992
2077
|
path: string;
|
|
1993
2078
|
branch: string;
|
|
1994
2079
|
taskId: number;
|
|
1995
|
-
reason: string; // 'task closed' | 'failed' | 'error' | 'stopped'
|
|
2080
|
+
reason: string; // 'task closed' | 'failed' | 'error' | 'stopped' | 'done' | 'merged'
|
|
1996
2081
|
exists: boolean; // worktree dir 가 아직 디스크에 있나(false = 이미 수동 삭제됨)
|
|
1997
2082
|
sizeKb?: number; // best-effort du -sk (실패 시 생략)
|
|
2083
|
+
/** v6.0 T6b — 이 worktree 가 미머지·미탈출 산출물의 **유일한 사본**인가(v4.1 close 가드와 같은 신호). */
|
|
2084
|
+
reclaimRisk: boolean;
|
|
1998
2085
|
}
|
|
1999
2086
|
|
|
2000
2087
|
/**
|
|
2001
2088
|
* 안전하게 회수 가능한 worktree 목록. worktreePath 가 비어있지 않은 모든 run 중
|
|
2002
|
-
* (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped}
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2089
|
+
* (a) task 가 closed 이거나 (b) run 상태 ∈ {failed, error, stopped} 이거나
|
|
2090
|
+
* (c) 격리 worktree 를 가진 채 정착한 done/merged(T6b — 쌓이던 그 빚).
|
|
2091
|
+
* running/preparing/pending/'open' 은 절대 포함하지 않는다(활성 작업).
|
|
2092
|
+
* exists=디스크 잔존 여부, sizeKb=best-effort du, reclaimRisk=유일 사본 경고.
|
|
2005
2093
|
*/
|
|
2006
2094
|
export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]> {
|
|
2007
2095
|
const allRuns = (await db.select().from(agentRuns)).filter((r) => !!r.worktreePath);
|
|
2008
2096
|
// task 상태 룩업(closed 판정용)
|
|
2009
2097
|
const taskById = new Map<number, typeof tasks.$inferSelect>();
|
|
2010
2098
|
for (const t of await db.select().from(tasks)) taskById.set(t.id, t);
|
|
2099
|
+
// 산출물이 이미 빠져나간 run — export·PR 이벤트 한 번이면 worktree 는 유일 사본이 아니다.
|
|
2100
|
+
const escaped = new Set<number>();
|
|
2101
|
+
for (const e of await db.select().from(agentEvents).where(inArray(agentEvents.kind, ['export', 'pr']))) {
|
|
2102
|
+
escaped.add(e.runId);
|
|
2103
|
+
}
|
|
2011
2104
|
|
|
2012
2105
|
const out: ReclaimableWorktree[] = [];
|
|
2013
2106
|
for (const run of allRuns) {
|
|
@@ -2015,11 +2108,18 @@ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]>
|
|
|
2015
2108
|
const task = taskById.get(run.taskId);
|
|
2016
2109
|
const taskClosed = task?.status === 'closed';
|
|
2017
2110
|
const statusReclaim = RECLAIM_STATUSES.has(run.status);
|
|
2018
|
-
|
|
2019
|
-
|
|
2111
|
+
const settled = RECLAIM_SETTLED_STATUSES.has(run.status);
|
|
2112
|
+
if (!taskClosed && !statusReclaim && !settled) continue; // open/running/preparing/pending 제외
|
|
2020
2113
|
|
|
2021
2114
|
// worktree 잔존 여부 + best-effort 사이즈(로컬만 정확; 원격은 machine 경유).
|
|
2022
2115
|
const ctx = await loadContext(run.id).catch(() => null);
|
|
2116
|
+
// done/merged 로 새로 들어온 run 은 **격리 worktree 를 가진 것만** — 루트 세션·in-place run 의
|
|
2117
|
+
// worktreePath 는 repo 체크아웃 그 자체라 회수할 디스크가 애초에 없다(기존 경로는 그대로 둔다).
|
|
2118
|
+
if (settled && !taskClosed && !statusReclaim && (!run.branch || (ctx && run.worktreePath === ctx.repoPath))) continue;
|
|
2119
|
+
const reason = taskClosed ? 'task closed' : run.status;
|
|
2120
|
+
// v4.1 close 가드와 같은 판정 — 정착 ∧ 변경있음 ∧ export·PR 없음. merged 는 이미 빠져나갔다.
|
|
2121
|
+
const reclaimRisk = RISK_STATUSES.has(run.status) && run.filesChanged > 0 && !escaped.has(run.id);
|
|
2122
|
+
|
|
2023
2123
|
let exists = false;
|
|
2024
2124
|
let sizeKb: number | undefined;
|
|
2025
2125
|
if (ctx) {
|
|
@@ -2034,24 +2134,82 @@ export async function listReclaimableWorktrees(): Promise<ReclaimableWorktree[]>
|
|
|
2034
2134
|
if (du.ok && Number.isFinite(n) && n > 0) sizeKb = n;
|
|
2035
2135
|
}
|
|
2036
2136
|
}
|
|
2037
|
-
out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb });
|
|
2137
|
+
out.push({ runId: run.id, path: run.worktreePath, branch: run.branch, taskId: run.taskId, reason, exists, sizeKb, reclaimRisk });
|
|
2038
2138
|
}
|
|
2039
2139
|
return out;
|
|
2040
2140
|
}
|
|
2041
2141
|
|
|
2142
|
+
// ── v6.0 T6b — 디스크 빚을 눈에 보이게 ──────────────────────────
|
|
2143
|
+
// 볼 수 없는 것은 관리할 수 없다. `.coxpit-worktrees` 의 총량·개수를 /api/health 와
|
|
2144
|
+
// 회수 판에 한 줄로 싣는다. du 는 큰 트리에서 느리므로 **health 는 절대 기다리지 않는다** —
|
|
2145
|
+
// 값은 캐시에서 나오고, 낡았으면 배경에서 다시 잰다(첫 호출은 값 없이 지나간다).
|
|
2146
|
+
|
|
2147
|
+
export interface WorktreeDisk { count: number; sizeKb: number }
|
|
2148
|
+
const DISK_TTL_MS = 30_000;
|
|
2149
|
+
let diskCache: { at: number; value: WorktreeDisk } | null = null;
|
|
2150
|
+
let diskInflight: Promise<WorktreeDisk> | null = null;
|
|
2151
|
+
|
|
2152
|
+
/** 로컬 머신 repo 들의 `.coxpit-worktrees` 부모 폴더를 한 번에 du -sk. 원격은 세지 않는다(health 는 이 머신의 디스크다). */
|
|
2153
|
+
async function measureWorktreeDisk(): Promise<WorktreeDisk> {
|
|
2154
|
+
const machineRows = await db.select().from(machines);
|
|
2155
|
+
const localIds = new Set(machineRows.filter((m) => m.kind === 'local' || m.address === '').map((m) => m.id));
|
|
2156
|
+
const dirs: string[] = [...new Set<string>(
|
|
2157
|
+
(await db.select().from(repos))
|
|
2158
|
+
.filter((r) => localIds.has(r.machineId))
|
|
2159
|
+
.map((r) => ppath.join(ppath.dirname(r.path), '.coxpit-worktrees')),
|
|
2160
|
+
)];
|
|
2161
|
+
if (!dirs.length) return { count: 0, sizeKb: 0 };
|
|
2162
|
+
// 폴더마다 "<KB> <개수>" 한 줄. 없는 폴더는 건너뛴다.
|
|
2163
|
+
const script = `for d in ${dirs.map(shq).join(' ')}; do [ -d "$d" ] || continue; ` +
|
|
2164
|
+
`s=$(du -sk "$d" 2>/dev/null | tail -1 | cut -f1); [ -n "$s" ] || s=0; ` +
|
|
2165
|
+
`c=$(ls -1 "$d" 2>/dev/null | wc -l); [ -n "$c" ] || c=0; echo "$s $c"; done`;
|
|
2166
|
+
const out = await runShellOn(LOCAL_MACHINE, script, 20000).catch(() => ({ ok: false as boolean, stdout: '' as string }));
|
|
2167
|
+
let count = 0, sizeKb = 0;
|
|
2168
|
+
for (const line of String(out.stdout || '').split('\n')) {
|
|
2169
|
+
const [s = '', c = ''] = line.trim().split(/\s+/);
|
|
2170
|
+
const kb = parseInt(s, 10), n = parseInt(c, 10);
|
|
2171
|
+
if (Number.isFinite(kb) && kb > 0) sizeKb += kb;
|
|
2172
|
+
if (Number.isFinite(n) && n > 0) count += n;
|
|
2173
|
+
}
|
|
2174
|
+
return { count, sizeKb };
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* 캐시된 worktree 디스크 사용량. darwin/linux 만(du 전제) — 그 밖은 null.
|
|
2179
|
+
* **절대 기다리지 않는다** — 낡았으면 배경에서 다시 재고 마지막 값을 그대로 돌려준다.
|
|
2180
|
+
* 아직 한 번도 못 쟀으면 null(=health 에 싣지 않는다). 디스크가 꽉 찬 머신에서 du 가
|
|
2181
|
+
* 오래 걸릴수록 health 는 더 빨라야 하지, 같이 멈추면 안 된다.
|
|
2182
|
+
*/
|
|
2183
|
+
export async function worktreeDisk(): Promise<WorktreeDisk | null> {
|
|
2184
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') return null;
|
|
2185
|
+
const cur = diskCache;
|
|
2186
|
+
if ((!cur || Date.now() - cur.at >= DISK_TTL_MS) && !diskInflight) {
|
|
2187
|
+
const p: Promise<WorktreeDisk> = measureWorktreeDisk()
|
|
2188
|
+
.then((v) => { diskCache = { at: Date.now(), value: v }; return v; })
|
|
2189
|
+
.catch(() => diskCache?.value ?? { count: 0, sizeKb: 0 })
|
|
2190
|
+
.finally(() => { if (diskInflight === p) diskInflight = null; });
|
|
2191
|
+
diskInflight = p;
|
|
2192
|
+
}
|
|
2193
|
+
return cur ? cur.value : null;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2042
2196
|
/**
|
|
2043
2197
|
* 회수 실행 — 대상 run(전체 또는 runIds 부분집합)의 worktree 를 되찾는다.
|
|
2044
2198
|
* dir 가 아직 있으면 cleanupRun 재사용(tmux kill + git worktree remove + branch -D + 포인터 blank).
|
|
2045
2199
|
* dir 가 이미 수동 삭제됐으면 git worktree prune + branch -D + DB 포인터 blank 만.
|
|
2046
2200
|
* 마지막에 영향받은 repo 마다 git worktree prune 1회(스테일 메타데이터 정리).
|
|
2047
2201
|
* 멱등 — 다시 돌려도 안전(이미 회수된 run 은 worktreePath 가 비어 목록에서 빠짐).
|
|
2202
|
+
*
|
|
2203
|
+
* v6.0 T6b — **전체 회수(runIds 없음)는 위험 표시가 없는 것만** 지운다. 미머지·미탈출
|
|
2204
|
+
* 산출물의 유일한 사본이 "전부 지우기" 한 번에 사라지는 일은 없다. 사람이 그 id 를
|
|
2205
|
+
* 직접 찍어 보냈다면(runIds) 그건 고른 것이므로 그대로 따른다. 라이브 run 은 어느 쪽도 아니다.
|
|
2048
2206
|
*/
|
|
2049
2207
|
export async function pruneWorktrees(runIds?: number[]): Promise<{
|
|
2050
2208
|
removed: Array<{ runId: number; detail: string }>; count: number;
|
|
2051
2209
|
}> {
|
|
2052
|
-
const reclaimable = await listReclaimableWorktrees();
|
|
2210
|
+
const reclaimable = (await listReclaimableWorktrees()).filter((r) => !isRunLive(r.runId));
|
|
2053
2211
|
const want = runIds && runIds.length ? new Set(runIds) : null;
|
|
2054
|
-
const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable;
|
|
2212
|
+
const targets = want ? reclaimable.filter((r) => want.has(r.runId)) : reclaimable.filter((r) => !r.reclaimRisk);
|
|
2055
2213
|
|
|
2056
2214
|
const removed: Array<{ runId: number; detail: string }> = [];
|
|
2057
2215
|
const affectedRepoPaths = new Map<string, MachineTarget>(); // repoPath -> machine (prune 대상)
|
|
@@ -2087,3 +2245,78 @@ export async function pruneWorktrees(runIds?: number[]): Promise<{
|
|
|
2087
2245
|
|
|
2088
2246
|
return { removed, count: removed.length };
|
|
2089
2247
|
}
|
|
2248
|
+
|
|
2249
|
+
// ── 고아 tmux 세션 수거(reaper) ─────────────────────────────────
|
|
2250
|
+
// run 을 지워도 tmux 세션은 남는다(데몬 재시작·DB 초기화·수동 삭제). 2026-09-17 에 손으로
|
|
2251
|
+
// 13개를 걷어낸 그 일을 제품의 동작으로 만든다. 규칙 셋만 지키면 안전하다:
|
|
2252
|
+
// ① DB 에 **없는** run id 의 `coxpit-r<N>` 만 후보다(살아 있는 run 의 세션은 목록에 아예 안 든다)
|
|
2253
|
+
// ② 페인이 빈 셸 이상을 돌리고 있으면 **표시만** 하고 절대 미리 고르지 않는다
|
|
2254
|
+
// ③ 죽일 때도 '=' 정확 일치 — coxpit-r5 가 coxpit-r50 을 물면 안 된다(전에 물었다)
|
|
2255
|
+
|
|
2256
|
+
/** tmux 이름에서 run id 를 읽는 유일한 형태. 숫자가 아니면 우리 것으로 치지 않는다. */
|
|
2257
|
+
const ORPHAN_SESSION_RE = /^coxpit-r(\d+)$/;
|
|
2258
|
+
/** "빈 셸" 로 볼 pane_current_command 들 — 이 밖이면 뭔가 돌고 있는 것으로 본다. */
|
|
2259
|
+
const IDLE_SHELLS = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh', 'login', '-sh', '-bash', '-zsh']);
|
|
2260
|
+
const LOCAL_MACHINE: MachineTarget = { slug: 'local', kind: 'local', address: '', sshUser: '' };
|
|
2261
|
+
|
|
2262
|
+
export interface OrphanTmuxSession {
|
|
2263
|
+
name: string; // coxpit-r<N>
|
|
2264
|
+
runId: number; // 이름에서 읽은 id (DB 에는 없다 — 그래서 고아다)
|
|
2265
|
+
command: string; // 페인에서 지금 돌고 있는 것(빈 셸이면 셸 이름)
|
|
2266
|
+
idle: boolean; // 모든 페인이 빈 셸인가 = 미리 체크해도 되는가
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* 로컬 머신의 고아 tmux 세션 목록. `tmux list-panes -a` 한 번으로 세션별 페인 명령까지 읽고,
|
|
2271
|
+
* DB 에 run 레코드가 있는 이름은 전부 빼고 돌려준다(= 살아 있는 세션은 절대 제안되지 않는다).
|
|
2272
|
+
* tmux 서버가 안 떠 있으면 빈 배열.
|
|
2273
|
+
*/
|
|
2274
|
+
export async function listOrphanTmux(): Promise<OrphanTmuxSession[]> {
|
|
2275
|
+
const r = await runShellOn(
|
|
2276
|
+
LOCAL_MACHINE,
|
|
2277
|
+
`tmux list-panes -a -F '#{session_name}\t#{pane_current_command}' 2>/dev/null || true`,
|
|
2278
|
+
8000,
|
|
2279
|
+
).catch(() => ({ stdout: '' as string }));
|
|
2280
|
+
|
|
2281
|
+
const panes = new Map<string, string[]>();
|
|
2282
|
+
for (const line of String(r.stdout || '').split('\n')) {
|
|
2283
|
+
const [name, cmd] = line.split('\t');
|
|
2284
|
+
if (!name || !ORPHAN_SESSION_RE.test(name)) continue;
|
|
2285
|
+
const arr = panes.get(name) ?? [];
|
|
2286
|
+
arr.push((cmd ?? '').trim());
|
|
2287
|
+
panes.set(name, arr);
|
|
2288
|
+
}
|
|
2289
|
+
if (!panes.size) return [];
|
|
2290
|
+
|
|
2291
|
+
const known = new Set((await db.select().from(agentRuns)).map((x) => x.id));
|
|
2292
|
+
const out: OrphanTmuxSession[] = [];
|
|
2293
|
+
for (const [name, cmds] of panes) {
|
|
2294
|
+
const runId = Number(ORPHAN_SESSION_RE.exec(name)![1]);
|
|
2295
|
+
if (known.has(runId)) continue; // 기록이 있는 run = 고아가 아니다
|
|
2296
|
+
const busy = cmds.find((c) => c && !IDLE_SHELLS.has(c));
|
|
2297
|
+
out.push({ name, runId, command: busy || cmds[0] || '', idle: !busy });
|
|
2298
|
+
}
|
|
2299
|
+
out.sort((a, b) => a.runId - b.runId);
|
|
2300
|
+
return out;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/**
|
|
2304
|
+
* 선택한 고아 세션 종료. 클라이언트가 보낸 이름을 믿지 않고 **지금 다시 고아 목록을 떠서**
|
|
2305
|
+
* 그 안에 있는 것만 죽인다(그 사이 run 이 생겼거나 이름이 지어졌으면 건너뛴다).
|
|
2306
|
+
* 타깃은 언제나 '=' 정확 일치.
|
|
2307
|
+
*/
|
|
2308
|
+
export async function killTmuxSessions(names: string[]): Promise<{
|
|
2309
|
+
killed: string[]; skipped: Array<{ name: string; reason: string }>; count: number;
|
|
2310
|
+
}> {
|
|
2311
|
+
const allowed = new Set((await listOrphanTmux()).map((o) => o.name));
|
|
2312
|
+
const killed: string[] = [];
|
|
2313
|
+
const skipped: Array<{ name: string; reason: string }> = [];
|
|
2314
|
+
for (const raw of names) {
|
|
2315
|
+
const name = String(raw).trim();
|
|
2316
|
+
if (!allowed.has(name)) { skipped.push({ name, reason: 'not an orphan session (live run, or already gone)' }); continue; }
|
|
2317
|
+
await runShellOn(LOCAL_MACHINE, `tmux kill-session -t ${shq('=' + name)} 2>/dev/null || true`, 8000)
|
|
2318
|
+
.catch(() => { /* best-effort — 이미 사라졌을 수 있다 */ });
|
|
2319
|
+
killed.push(name);
|
|
2320
|
+
}
|
|
2321
|
+
return { killed, skipped, count: killed.length };
|
|
2322
|
+
}
|