coxpit 6.1.0 → 6.3.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
@@ -115,4 +115,6 @@ export async function ensureSchema(): Promise<void> {
115
115
  try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
116
116
  try { await client.execute("ALTER TABLE agent_runs ADD COLUMN title TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
117
117
  try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
118
+ // DEFAULT 1 = 기존 run 은 real 로 남는다. 모르는 과거를 dry 로 칠하지 않기 위한 기본값이다(v5.28 H1).
119
+ try { await client.execute('ALTER TABLE agent_runs ADD COLUMN real INTEGER NOT NULL DEFAULT 1'); } catch { /* exists */ }
118
120
  }
package/src/db/schema.ts CHANGED
@@ -93,6 +93,10 @@ export const agentRuns = sqliteTable('agent_runs', {
93
93
  sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
94
94
  prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
95
95
  model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
96
+ // v5.28 Part H — 이 run 이 진짜 에이전트로 돌았나(real), 모의 스트림이었나(dry).
97
+ // 기본 true(real)는 **의도**다: 이 컬럼이 생기기 전 run 은 알 수 없고, 모르는 것을 dry 라
98
+ // 부르면 거짓 경보가 된다. 아는 dry 만 표시한다 — dry 를 배지하되, dry 를 추측하지 않는다.
99
+ real: integer('real', { mode: 'boolean' }).notNull().default(true),
96
100
  filesChanged: integer('files_changed').notNull().default(0),
97
101
  agentPid: integer('agent_pid').notNull().default(0), // detached sh pgid — 재시작 후 생존 판정/stop
98
102
  logOffset: integer('log_offset').notNull().default(0), // 내구 로그에서 tail 이 소비한 바이트(재-adopt 시 여기부터)
@@ -0,0 +1,62 @@
1
+ // 이벤트 인간화 — 보드와 코크핏이 **같은 한 벌**을 쓴다(사본 금지).
2
+ // 보드 모달의 타임라인과 코크핏 활동 페인(v5.28 E)이 같은 줄을 같은 규칙으로 보여야 하므로,
3
+ // 클라이언트 JS 를 여기 한 곳에 두고 두 페이지의 <script> 에 그대로 끼워 넣는다(icons.ts 전례).
4
+ // 문자열 안의 이스케이프는 **클라이언트 기준**이다: \\n 은 클라이언트의 \n 이 된다.
5
+ export const HUMANIZE_JS = `/* 이벤트 인간화 — JSON 원문 대신 사람이 읽는 한 줄로. null = 표시 생략(노이즈). */
6
+ function humanize(e){
7
+ const kind = e.kind, payload = e.payload;
8
+ if (kind === 'rate_limit_event') return null;
9
+ if (kind === 'steer') return { k:'steer', t:'→ '+payload };
10
+ if (kind === 'ask') return { k:'ask', t:'? '+payload };
11
+ if (kind === 'sync') return { k:'sync', t:payload };
12
+ if (kind === 'export'){ try{ const o=JSON.parse(payload); return { k:'export', t:o.copied+' file(s) → '+o.dest }; }catch{ return { k:'export', t:payload }; } }
13
+ if (kind === 'pr') return { k:'pr', t:payload };
14
+ if (kind === 'stderr') return { k:'stderr', t:payload };
15
+ try{
16
+ const o = JSON.parse(payload);
17
+ if (o.type === 'system'){
18
+ if (o.subtype === 'init' || !o.subtype) return { k:'session',
19
+ t:'started'+(o.model?' · '+String(o.model).replace(/\\u001b\\[[0-9;]*m/g,'')
20
+ .replace(/\\x1b\\[[0-9;]*m/g,'') : '') };
21
+ if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
22
+ return null; // thinking_tokens 등 스트림 잡음
23
+ }
24
+ if (o.type === 'user') return null; // tool 결과 회신 — 노이즈
25
+ if (o.type === 'assistant' && o.message){
26
+ const parts = [];
27
+ for (const x of (o.message.content||[])){
28
+ if (x.type === 'text' && x.text) parts.push({ k:'said', t:x.text });
29
+ else if (x.type === 'tool_use'){
30
+ const i = x.input || {};
31
+ const arg = i.file_path || i.command || i.path || i.pattern || '';
32
+ parts.push({ k:'tool', t:'▸ '+x.name+(arg?' — '+String(arg).split('/').slice(-2).join('/').slice(0,60):'') });
33
+ }
34
+ }
35
+ return parts.length ? parts : null;
36
+ }
37
+ if (o.type === 'assistant' && o.text) return { k:'said', t:o.text };
38
+ if (o.type === 'result') return { k:'done', t:o.result || 'finished' };
39
+ if (kind === 'meta' && o.subtask) return { k:'swarm', t:'↳ spawned task #'+o.subtask+' — '+String(o.title||'').slice(0,60)+' ('+((o.runs||[]).map(x=>'r'+x).join(' '))+')' };
40
+ if (kind === 'meta') return { k:'start', t:'worktree '+String(o.worktree||'').split('/').slice(-2).join('/') };
41
+ return { k:kind, t:payload.slice(0,140) };
42
+ }catch{
43
+ // 파싱 실패(과거에 잘려 저장된 이벤트 등) — JSON 잔해를 그대로 보여주지 않는다:
44
+ // text 조각만 구제하고, 없으면 생략.
45
+ if (payload.trim().startsWith('{')){
46
+ const texts = [];
47
+ const re = /"text":"((?:[^"\\\\]|\\\\.)*)"/g; let m;
48
+ while ((m = re.exec(payload)) && texts.length < 2) texts.push(m[1].replace(/\\\\n/g,' ').slice(0,140));
49
+ return texts.length ? { k:'said', t:texts.join(' · ') } : null;
50
+ }
51
+ return { k:kind, t:payload };
52
+ }
53
+ }
54
+ function humanLines(events){
55
+ const out = [];
56
+ for (const e of (events||[])){
57
+ const h = humanize(e);
58
+ if (!h) continue;
59
+ if (Array.isArray(h)) out.push(...h); else out.push(h);
60
+ }
61
+ return out;
62
+ }`;
@@ -344,10 +344,10 @@ export async function spawnSubtasks(parentRunId: number, title: string, prompt:
344
344
  const runIds: number[] = [];
345
345
  for (let i = 0; i < n; i++) {
346
346
  const rIns = await db.insert(agentRuns).values({
347
- taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending',
347
+ taskId: task.id, machineId: pr.machineId, agent: pr.agent, model: pr.model, status: 'pending', real,
348
348
  }).returning();
349
349
  const run = rIns[0]!;
350
- broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
350
+ broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, real, branch: '', filesChanged: 0 });
351
351
  void launchRun(run.id, real);
352
352
  runIds.push(run.id);
353
353
  }
@@ -484,7 +484,9 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
484
484
  const session = `coxpit-r${runId}`;
485
485
 
486
486
  try {
487
- await setRun(runId, { status: 'preparing', branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
487
+ // real 여기서 각인한다 명령을 고르는 값이 run 의 사실이다(v5.28 H1).
488
+ // 'preparing' 에 실어 두면 worktree 단계에서 넘어져도 "이 run 은 모의였다"가 남는다.
489
+ await setRun(runId, { status: 'preparing', real: !!useReal, branch, worktreePath: wtPath, tmuxWindow: session, startedAt: new Date() });
488
490
 
489
491
  // 1) worktree 생성(격리 브랜치) — in-place 는 건너뛴다(격리가 없는 것이 요점).
490
492
  if (!inPlace) {
@@ -607,6 +609,17 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
607
609
  void notifySettle(runId, status, filesChanged, exitSummary);
608
610
  }
609
611
 
612
+ /**
613
+ * verify 러너 — 명령 한 번, 꼬리 한 줌. 어디서 돌리든 판정 규칙은 하나여야 해서
614
+ * verifyRun(run 의 worktree)·verifyBase(머지된 base)가 이 함수를 같이 쓴다.
615
+ */
616
+ async function runVerifyCmd(cmd: string, cwd: string, machine: MachineTarget): Promise<{ status: string; output: string }> {
617
+ const r = await runShellOn(machine, `cd ${shq(cwd)} && ( ${cmd} )`, 180000);
618
+ const merged = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
619
+ const output = merged.length > 6000 ? '…' + merged.slice(-6000) : merged;
620
+ return { status: r.ok ? 'pass' : r.code === -1 ? 'error' : 'fail', output };
621
+ }
622
+
610
623
  /**
611
624
  * Verify in-loop — repo.verifyCmd 를 run 의 worktree 에서 실행해 pass/fail 을 기록.
612
625
  * verifyCmd 미설정이면 상태를 비우고 no-op. 정착 훅이 자동 호출(done+변경), 수동 재검증도 지원.
@@ -626,12 +639,34 @@ export async function verifyRun(runId: number): Promise<{ ok: boolean; status: s
626
639
  return { ok: false, status: 'error', detail: 'worktree missing' };
627
640
  }
628
641
  await setRun(runId, { verifyStatus: 'running', verifyOutput: '' });
629
- const r = await runShellOn(ctx.machine, `cd ${shq(run.worktreePath)} && ( ${cmd} )`, 180000);
630
- const merged = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
631
- const tail = merged.length > 6000 ? '…' + merged.slice(-6000) : merged;
632
- const status = r.ok ? 'pass' : r.code === -1 ? 'error' : 'fail';
633
- await setRun(runId, { verifyStatus: status, verifyOutput: tail });
634
- return { ok: true, status };
642
+ const v = await runVerifyCmd(cmd, run.worktreePath, ctx.machine);
643
+ await setRun(runId, { verifyStatus: v.status, verifyOutput: v.output });
644
+ return { ok: true, status: v.status };
645
+ }
646
+
647
+ /**
648
+ * 머지된 base 검증(v5.28 J1) — repo.verifyCmd 를 **repo.path**(머지가 막 내려앉은 기본 브랜치)에서
649
+ * 돌린다. 승자가 내려앉은 그 호흡에 같은 명령을 돌려 pass/fail 을 그 자리에서 말하기 위한 것이라,
650
+ * 지나간 worktree 가 아니라 base 를 본다.
651
+ * - verifyCmd 가 비어 있으면 아무 명령도 추측하지 않고 no-op(status '').
652
+ * - best-effort — 실패는 보고일 뿐, 머지를 되돌리지 않는다.
653
+ */
654
+ export async function verifyBase(repoId: number): Promise<{ status: string; output: string }> {
655
+ const none = { status: '', output: '' };
656
+ const rp = await db.select().from(repos).where(eq(repos.id, repoId)).limit(1);
657
+ const repo = rp[0];
658
+ if (!repo) return none;
659
+ const cmd = (repo.verifyCmd ?? '').trim();
660
+ if (!cmd) return none;
661
+ const mr = await db.select().from(machines).where(eq(machines.id, repo.machineId)).limit(1);
662
+ const m = mr[0];
663
+ if (!m) return none;
664
+ const machine: MachineTarget = { slug: m.slug, kind: m.kind, address: m.address, sshUser: m.sshUser };
665
+ try {
666
+ return await runVerifyCmd(cmd, repo.path, machine);
667
+ } catch (e) {
668
+ return { status: 'error', output: String(e).slice(0, 300) };
669
+ }
635
670
  }
636
671
 
637
672
  /**
@@ -1267,9 +1302,9 @@ export async function launchGroupTask(
1267
1302
  const machineId = rp[0]!.machineId;
1268
1303
  const tIns = await db.insert(tasks).values({ repoId, title: title.slice(0, 140), prompt, groupId }).returning();
1269
1304
  const task = tIns[0]!;
1270
- const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending' }).returning();
1305
+ const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId, agent: 'claude-code', status: 'pending', real }).returning();
1271
1306
  const run = rIns[0]!;
1272
- broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, branch: '', filesChanged: 0 });
1307
+ broadcast({ type: 'run', runId: run.id, taskId: task.id, status: 'pending', agent: run.agent, real, branch: '', filesChanged: 0 });
1273
1308
  void launchRun(run.id, real);
1274
1309
  return { id: task.id, title: task.title, runId: run.id };
1275
1310
  }
@@ -1512,6 +1547,8 @@ export interface IntegrateResult {
1512
1547
  detail?: string;
1513
1548
  integrationTaskId?: number;
1514
1549
  integrationRunId?: number;
1550
+ /** 머지된 건에 한해 base 검증 결과(v5.28 J1). verifyCmd 가 없으면 status ''. */
1551
+ verify?: { status: string; output: string };
1515
1552
  }
1516
1553
 
1517
1554
  /**
@@ -1529,7 +1566,13 @@ export async function integrateRuns(runIds: number[], real?: boolean): Promise<I
1529
1566
  if (run.status === 'merged') { results.push({ runId: id, status: 'skipped', detail: 'already merged' }); continue; }
1530
1567
 
1531
1568
  const m = await mergeRun(id);
1532
- if (m.ok) { results.push({ runId: id, status: 'merged' }); continue; }
1569
+ if (m.ok) {
1570
+ // 내려앉은 그 호흡에 base 를 검증한다(J1) — 실패해도 머지는 그대로 서 있고, 보고만 된다.
1571
+ const mc = await loadContext(id);
1572
+ const verify = mc ? await verifyBase(mc.repoId) : { status: '', output: '' };
1573
+ results.push({ runId: id, status: 'merged', verify });
1574
+ continue;
1575
+ }
1533
1576
  if (!m.conflict) { results.push({ runId: id, status: 'skipped', detail: m.detail }); continue; }
1534
1577
 
1535
1578
  // 충돌 → 통합 태스크 자동 발사 (에이전트가 머지를 대신 푼다)
@@ -1544,9 +1587,9 @@ export async function integrateRuns(runIds: number[], real?: boolean): Promise<I
1544
1587
  `Do not modify files unrelated to the conflicts.`;
1545
1588
  const tIns = await db.insert(tasks).values({ repoId: ctx.repoId, title, prompt }).returning();
1546
1589
  const newTask = tIns[0]!;
1547
- const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending' }).returning();
1590
+ const rIns = await db.insert(agentRuns).values({ taskId: newTask.id, machineId: ctx.machineId, agent: 'claude-code', status: 'pending', real: real ?? true }).returning();
1548
1591
  const newRun = rIns[0]!;
1549
- broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, branch: '', filesChanged: 0 });
1592
+ broadcast({ type: 'run', runId: newRun.id, taskId: newTask.id, status: 'pending', agent: newRun.agent, real: real ?? true, branch: '', filesChanged: 0 });
1550
1593
  void launchRun(newRun.id, real ?? true);
1551
1594
  results.push({ runId: id, status: 'conflict', detail: m.detail, integrationTaskId: newTask.id, integrationRunId: newRun.id });
1552
1595
  }
package/src/server.ts CHANGED
@@ -21,7 +21,7 @@ import { db } from './db';
21
21
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
22
22
  import { BOOKMARKLET_JS } from './design';
23
23
  import { runShellOn, shq } from './exec';
24
- import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt, deleteSession, getScrollback, getRunPwd, getSessionChat } from './orchestrator';
24
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, verifyBase, openSessionAt, deleteSession, getScrollback, getRunPwd, getSessionChat } from './orchestrator';
25
25
  import { openTerm } from './term';
26
26
  import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates, spottedPorts } from './agentstate';
27
27
  import { scanListeners, scanPort, killPid, dropCache as dropListenerCache } from './procscan';
@@ -383,7 +383,8 @@ export async function buildServer(): Promise<FastifyInstance> {
383
383
  machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
384
384
  runs: rns.map((r) => {
385
385
  const sig = noopSignal(r.status, r.filesChanged, r.exitSummary, taskOut.get(r.taskId) ?? '[]');
386
- return { ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
386
+ // real 언제나 불리언으로 나간다 클라이언트는 real===false 하나만 보고 dry 칩을 그린다.
387
+ return { ...r, real: !!r.real, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
387
388
  }),
388
389
  counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
389
390
  // 지금 터미널이 붙어 있는 run 의 에이전트 상태(runId → {state,detail,ts}).
@@ -500,7 +501,7 @@ export async function buildServer(): Promise<FastifyInstance> {
500
501
  taskId: t.id, title: t.title, repoName: repoName.get(t.repoId) ?? '?',
501
502
  groupTitle: t.groupId != null ? grpTitle.get(t.groupId) ?? null : null,
502
503
  closedAt: t.closedAt ? Math.floor(t.closedAt.getTime() / 1000) : (t.createdAt ? Math.floor(t.createdAt.getTime() / 1000) : 0),
503
- runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model })),
504
+ runs: rs.map((r) => ({ id: r.id, status: r.status, filesChanged: r.filesChanged, agent: r.agent, model: r.model, real: !!r.real })),
504
505
  });
505
506
  }
506
507
  // status 필터가 있으면 total 은 근사(페이지 내 필터) — UI 는 rows 로만 판단하니 total0 유지.
@@ -958,7 +959,7 @@ export async function buildServer(): Promise<FastifyInstance> {
958
959
  const tr = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
959
960
  if (!tr[0]) return reply.code(404).send({ error: 'not found' });
960
961
  const runs = await db.select().from(agentRuns).where(eq(agentRuns.taskId, id));
961
- return { task: tr[0], runs };
962
+ return { task: tr[0], runs: runs.map((r) => ({ ...r, real: !!r.real })) };
962
963
  });
963
964
 
964
965
  // 태스크 이름 변경(=세션 이름 변경) + v6.0 S2 승격(repoId 재부모화).
@@ -1089,16 +1090,19 @@ export async function buildServer(): Promise<FastifyInstance> {
1089
1090
  });
1090
1091
  }
1091
1092
  }
1093
+ // dry/real 은 run 의 성질이다 — 만들 때부터 각인한다(v5.28 H1). body 가 말이 없으면
1094
+ // launchRun 이 쓰게 될 그 기본값(config.agent.real)을 그대로 쓴다: 행과 명령이 갈리면 안 된다.
1095
+ const useReal = b.real === undefined ? config.agent.real : !!b.real;
1092
1096
  const created: Array<typeof agentRuns.$inferSelect> = [];
1093
1097
  for (let i = 0; i < count; i++) {
1094
1098
  const ins = await db.insert(agentRuns)
1095
- .values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending' })
1099
+ .values({ taskId: id, machineId: rp[0].machineId, agent, model, title, inPlace, status: 'pending', real: useReal })
1096
1100
  .returning();
1097
1101
  created.push(ins[0]!);
1098
1102
  }
1099
1103
  // 보드가 taskId 를 알도록 생성 브로드캐스트 후 백그라운드 시작.
1100
1104
  for (const r of created) {
1101
- broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, branch: '', filesChanged: 0 });
1105
+ broadcast({ type: 'run', runId: r.id, taskId: id, status: 'pending', agent, title, inPlace, real: useReal, branch: '', filesChanged: 0 });
1102
1106
  void launchRun(r.id, b.real);
1103
1107
  }
1104
1108
  return reply.code(202).send({ ok: true, runs: created.map((r) => ({ id: r.id, status: r.status })) });
@@ -1124,7 +1128,7 @@ export async function buildServer(): Promise<FastifyInstance> {
1124
1128
  const runsOut = [];
1125
1129
  for (const r of trs) {
1126
1130
  const d = await getRunDiff(r.id);
1127
- runsOut.push({ ...r, diff: d.ok ? d.diff : '', stat: d.ok ? d.stat : d.stat });
1131
+ runsOut.push({ ...r, real: !!r.real, diff: d.ok ? d.diff : '', stat: d.ok ? d.stat : d.stat });
1128
1132
  }
1129
1133
  return { task: tr[0], runs: runsOut };
1130
1134
  });
@@ -1165,7 +1169,7 @@ export async function buildServer(): Promise<FastifyInstance> {
1165
1169
  const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1166
1170
  if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1167
1171
  const events = await db.select().from(agentEvents).where(eq(agentEvents.runId, id));
1168
- return { run: rr[0], events };
1172
+ return { run: { ...rr[0], real: !!rr[0].real }, events };
1169
1173
  });
1170
1174
 
1171
1175
  // v6.0 T4 — run 의 역할 이름 변경(탭 더블클릭). title 만 받는다.
@@ -1226,7 +1230,11 @@ export async function buildServer(): Promise<FastifyInstance> {
1226
1230
  if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1227
1231
  const res = await mergeRun(id);
1228
1232
  if (!res.ok) return reply.code(409).send(res);
1229
- return res;
1233
+ // 내려앉은 그 호흡에 머지된 base 를 검증한다(v5.28 J1). verifyCmd 가 없으면 status ''(no-op),
1234
+ // 실패해도 머지는 되돌리지 않는다 — 보고만 한다.
1235
+ const tr = await db.select().from(tasks).where(eq(tasks.id, rr[0].taskId)).limit(1);
1236
+ const verify = tr[0] ? await verifyBase(tr[0].repoId) : { status: '', output: '' };
1237
+ return { ...res, verify };
1230
1238
  });
1231
1239
 
1232
1240
  // 후속 지시(steer) — 정착한 run 을 같은 세션(--resume)·같은 worktree 로 계속.
@@ -1445,6 +1453,7 @@ export async function buildServer(): Promise<FastifyInstance> {
1445
1453
  const runs = g.rows.map(({ run, task }) => ({
1446
1454
  runId: run.id, taskId: task.id, title: task.title, status: run.status,
1447
1455
  agent: run.agent, model: run.model, branch: run.branch, filesChanged: run.filesChanged,
1456
+ real: !!run.real,
1448
1457
  live: isRunLive(run.id), steerable: isSteerable(run),
1449
1458
  // 수렴 콕핏 결정 행용: 태스크 닫힘 여부 + worktree 생존(터미널 가드·머지 가능성 판단).
1450
1459
  taskStatus: task.status, hasWorktree: !!run.worktreePath,