coxpit 5.0.2 → 5.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/package.json +1 -1
- package/src/board.ts +122 -25
- package/src/orchestrator.ts +280 -11
- package/src/server.ts +43 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/board.ts
CHANGED
|
@@ -185,9 +185,24 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
185
185
|
.sheet-h .sp{flex:1}.sheet-h .x{color:var(--muted);cursor:pointer;background:none;border:none;padding:2px}
|
|
186
186
|
.sheet #taskForm{display:flex;flex-direction:column;gap:13px;min-height:0}
|
|
187
187
|
.sheet-body{display:flex;flex-direction:column;gap:8px;overflow-y:auto;min-height:0;max-height:60vh;padding:1px}
|
|
188
|
-
.sheet-f{display:flex;align-items:center;gap:
|
|
189
|
-
.sheet-f #modeSeg{flex:
|
|
190
|
-
.sheet-f #
|
|
188
|
+
.sheet-f{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding-top:12px;border-top:1px solid var(--line)}
|
|
189
|
+
.sheet-f #modeSeg{flex:0 0 auto}
|
|
190
|
+
.sheet-f #taskCountStep{flex:0 0 auto;margin-left:auto}
|
|
191
|
+
.sheet-f #runFleetBtn{flex:1 1 100%;justify-content:center}
|
|
192
|
+
/* agent-mode switch — compact toggle (dry ⇄ real), never a native checkbox/white box */
|
|
193
|
+
.realtog{display:inline-flex;align-items:center;gap:8px;background:#0e1118;border:1px solid var(--line);
|
|
194
|
+
border-radius:999px;padding:5px 13px 5px 6px;cursor:pointer;font-family:var(--sans);font-size:12px;
|
|
195
|
+
font-weight:600;color:var(--muted);white-space:nowrap;transition:color .15s,border-color .15s}
|
|
196
|
+
.realtog:hover{border-color:var(--brand)}
|
|
197
|
+
.realtog .realtog-knob{width:30px;height:17px;border-radius:999px;background:var(--surface2);
|
|
198
|
+
position:relative;flex:none;transition:background .18s}
|
|
199
|
+
.realtog .realtog-knob::after{content:"";position:absolute;top:2px;left:2px;width:13px;height:13px;
|
|
200
|
+
border-radius:50%;background:var(--faint);transition:transform .18s,background .18s}
|
|
201
|
+
.realtog .realtog-hint{font-family:var(--mono);font-size:10px;font-weight:400;color:var(--faint)}
|
|
202
|
+
.realtog[aria-checked="true"]{color:var(--brand);border-color:rgba(78,201,176,.4)}
|
|
203
|
+
.realtog[aria-checked="true"] .realtog-knob{background:var(--brand-dim)}
|
|
204
|
+
.realtog[aria-checked="true"] .realtog-knob::after{transform:translateX(13px);background:var(--brand)}
|
|
205
|
+
.realtog:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
|
|
191
206
|
/* progressive Options reveal (rarely-used Task fields) */
|
|
192
207
|
.opt{border:1px solid var(--line);border-radius:var(--r-ctl);background:#0e1118;overflow:hidden}
|
|
193
208
|
.opt-h{width:100%;display:flex;align-items:center;gap:8px;background:transparent;border:none;cursor:pointer;
|
|
@@ -253,7 +268,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
253
268
|
.seg{display:flex;gap:3px;padding:3px;border:1px solid var(--line);border-radius:var(--r-ctl);background:#0e1118}
|
|
254
269
|
/* slim single-row option — matches the type/provider seg height; the risky hint rides inline, subtle */
|
|
255
270
|
.seg-opt{flex:1;display:inline-flex;flex-direction:row;align-items:center;justify-content:center;gap:5px;
|
|
256
|
-
padding:6px 8px;border:none;min-width:0;
|
|
271
|
+
padding:6px 8px;border:none;min-width:0;white-space:nowrap;line-height:1.2;
|
|
257
272
|
background:transparent;color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;
|
|
258
273
|
border-radius:calc(var(--r-ctl) - 3px);transition:background .15s,color .15s}
|
|
259
274
|
.seg-opt:hover{color:var(--ink)}
|
|
@@ -297,6 +312,19 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
297
312
|
.gband-fold{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px}
|
|
298
313
|
.gband.folded .gband-grid{display:none}
|
|
299
314
|
.gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
|
|
315
|
+
/* v5.1 A3 — sibling overlap panel */
|
|
316
|
+
.gband-overlap{margin:0 0 10px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;
|
|
317
|
+
background:#0e1118;font-family:var(--mono);font-size:11.5px;color:var(--muted)}
|
|
318
|
+
.gov-load{color:var(--faint)}
|
|
319
|
+
.gov-clean{color:var(--brand);display:flex;align-items:center;gap:6px}
|
|
320
|
+
.gov-clean .ic{width:13px;height:13px}
|
|
321
|
+
.gov-t{color:var(--ink);margin-bottom:6px}
|
|
322
|
+
.gov-list{list-style:none;margin:0 0 6px;padding:0;display:flex;flex-direction:column;gap:3px}
|
|
323
|
+
.gov-list li{display:flex;align-items:center;gap:8px}
|
|
324
|
+
.gov-list code{background:var(--surface2);border:1px solid var(--line);border-radius:4px;padding:0 5px;color:var(--ink)}
|
|
325
|
+
.gov-r{color:#c9922e}
|
|
326
|
+
.gov-order{color:var(--muted);border-top:1px solid var(--line);padding-top:6px;margin-top:2px}
|
|
327
|
+
.gov-order b{color:var(--brand)}
|
|
300
328
|
.attempt{color:var(--brand);opacity:.8}
|
|
301
329
|
|
|
302
330
|
/* ── goal workroom (v4.6) — 한 goal 을 여는 단일 방 ── */
|
|
@@ -437,6 +465,10 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
437
465
|
.chip.running i{animation:pulse 1.2s ease-in-out infinite}
|
|
438
466
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
439
467
|
@media (prefers-reduced-motion:reduce){.chip.running i{animation:none}.card:hover{transform:none}}
|
|
468
|
+
/* v5.1 A2 — a settled run that changed nothing (intent-gated); 'blocked' = likely stalled on approval */
|
|
469
|
+
.chip.noop{color:#c9922e;border-color:rgba(201,146,46,.45)}
|
|
470
|
+
.chip.noop.blk{color:#e0955a;border-color:rgba(224,149,90,.6)}
|
|
471
|
+
.card-h .chip.noop::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:1px}
|
|
440
472
|
.meta{display:flex;gap:16px;padding:0 14px 10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
441
473
|
.meta b{color:var(--muted);font-weight:500;font-variant-numeric:tabular-nums}
|
|
442
474
|
.meta .resumable{color:var(--brand);opacity:.75}
|
|
@@ -884,10 +916,11 @@ ${ICON_SPRITE}
|
|
|
884
916
|
</div>
|
|
885
917
|
</div>
|
|
886
918
|
<div class="sheet-f" id="sheetFooter">
|
|
887
|
-
<
|
|
888
|
-
<
|
|
889
|
-
<
|
|
890
|
-
|
|
919
|
+
<button type="button" id="modeSeg" class="realtog" role="switch" aria-checked="false" title="Dry run rehearses the full pipeline with zero credits. Turn on to run the real agent CLI.">
|
|
920
|
+
<span class="realtog-knob"></span>
|
|
921
|
+
<span class="realtog-lbl" id="modeRealLbl">Dry run</span>
|
|
922
|
+
<span class="realtog-hint" id="modeRealHint">rehearsal</span>
|
|
923
|
+
</button>
|
|
891
924
|
<input type="checkbox" id="taskReal" hidden />
|
|
892
925
|
<div class="stepper" id="taskCountStep" title="agents — 1 for a job, N to explore variants">
|
|
893
926
|
<button type="button" class="stp" id="cntDown" aria-label="fewer agents"><svg class="ic"><use href="#i-minus"/></svg></button>
|
|
@@ -1449,11 +1482,38 @@ function bandHTML(g, grpRuns){
|
|
|
1449
1482
|
+ '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
|
|
1450
1483
|
+ '<span class="gband-sp"></span>'
|
|
1451
1484
|
+ '<button class="btn-ghost sm gband-open" data-groom="'+g.id+'">⌒ Open workroom</button>'
|
|
1485
|
+
+ '<button class="btn-ghost sm" data-goverlap="'+g.id+'" title="which files sibling runs both touch — plan the land order">⧉ Overlap</button>'
|
|
1452
1486
|
+ '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
|
|
1453
1487
|
+ '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
|
|
1454
1488
|
+ '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
|
|
1489
|
+
+ '<div class="gband-overlap" id="gov-'+g.id+'" hidden></div>'
|
|
1455
1490
|
+ '<div class="gband-grid">'+cards+'</div></div>';
|
|
1456
1491
|
}
|
|
1492
|
+
// v5.1 A3 — sibling overlap: on-demand fetch, render contended files + suggested land order.
|
|
1493
|
+
async function toggleOverlap(gid){
|
|
1494
|
+
const box = $('gov-'+gid); if(!box) return;
|
|
1495
|
+
if (!box.hidden){ box.hidden = true; box.innerHTML=''; return; }
|
|
1496
|
+
box.hidden = false; box.innerHTML = '<span class="gov-load">checking overlap…</span>';
|
|
1497
|
+
try {
|
|
1498
|
+
const res = await fetch('/api/groups/'+gid+'/overlap');
|
|
1499
|
+
box.innerHTML = overlapHTML(await res.json());
|
|
1500
|
+
} catch(e){ box.innerHTML = '<span class="gov-load">overlap check failed</span>'; }
|
|
1501
|
+
}
|
|
1502
|
+
function overlapHTML(j){
|
|
1503
|
+
const runs = j.runs||[];
|
|
1504
|
+
if (!runs.length) return '<span class="gov-load">no run diffs yet — land order needs settled runs</span>';
|
|
1505
|
+
const cont = j.contended||[];
|
|
1506
|
+
const order = (j.order||[]).map(id=>'r'+id).join(' → ');
|
|
1507
|
+
let h = '';
|
|
1508
|
+
if (!cont.length){
|
|
1509
|
+
h += '<div class="gov-clean">'+ic('check')+' no file overlap — siblings touch disjoint files, land in any order</div>';
|
|
1510
|
+
} else {
|
|
1511
|
+
h += '<div class="gov-t">'+cont.length+' contended file'+(cont.length>1?'s':'')+' — plan the sequence</div>';
|
|
1512
|
+
h += '<ul class="gov-list">'+cont.map(c=>'<li><code>'+esc(c.path)+'</code><span class="gov-r">'+c.runIds.map(i=>'r'+i).join(' · ')+'</span></li>').join('')+'</ul>';
|
|
1513
|
+
}
|
|
1514
|
+
if (order) h += '<div class="gov-order">suggested land order · <b>'+esc(order)+'</b></div>';
|
|
1515
|
+
return h;
|
|
1516
|
+
}
|
|
1457
1517
|
// v5.0 — 레일에서 고른 repo 로 run 을 스코프(client-side). selectedRepo=null → 전체.
|
|
1458
1518
|
function runInScope(r){
|
|
1459
1519
|
if (selectedRepo==null) return true;
|
|
@@ -1662,7 +1722,11 @@ function cardHTML(r){
|
|
|
1662
1722
|
const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
|
|
1663
1723
|
return '<div class="card'+selCls+'" id="card-'+r.id+'">'
|
|
1664
1724
|
+ '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
|
|
1665
|
-
+ '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)
|
|
1725
|
+
+ '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)
|
|
1726
|
+
+ (r.noop ? '<span class="chip noop'+(r.noopReason==='blocked'?' blk':'')+'" title="'
|
|
1727
|
+
+(r.noopReason==='blocked'?'settled without making the change it was asked for — likely stalled on approval':'this run changed no files — did it actually do the work?')
|
|
1728
|
+
+'">'+(r.noopReason==='blocked'?'blocked':'no changes')+'</span>' : '')
|
|
1729
|
+
+ '</div>'
|
|
1666
1730
|
+ '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
|
|
1667
1731
|
+ '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
|
|
1668
1732
|
+ '<span>'+esc(r.agent||'')+'</span>'
|
|
@@ -1764,19 +1828,19 @@ function renderRail(){
|
|
|
1764
1828
|
const cnt = railCounts();
|
|
1765
1829
|
let html = '';
|
|
1766
1830
|
if (repos.length){
|
|
1767
|
-
html += '<
|
|
1768
|
-
+ ic('layers')+'<span class="nm">All repositories</span></
|
|
1831
|
+
html += '<div class="repo'+(selectedRepo==null?' on':'')+'" role="button" tabindex="0" data-repo="all">'
|
|
1832
|
+
+ ic('layers')+'<span class="nm">All repositories</span></div>';
|
|
1769
1833
|
for (const r of repos){
|
|
1770
1834
|
const c = cnt.get(r.id) || { active:0, attn:false };
|
|
1771
1835
|
const on = selectedRepo===r.id;
|
|
1772
|
-
html += '<
|
|
1836
|
+
html += '<div class="repo'+(on?' on':'')+'" role="button" tabindex="0" data-repo="'+r.id+'" title="'+escA(r.path||r.name)+'">'
|
|
1773
1837
|
+ ic('folder')+'<span class="nm">'+esc(r.name)+'</span>'
|
|
1774
1838
|
+ (c.attn ? '<span class="attn" title="a run needs a hand"></span>' : '')
|
|
1775
1839
|
+ (c.active>0 ? '<span class="cnt">'+c.active+'</span>' : '')
|
|
1776
1840
|
+ '<span class="rowmenu">'
|
|
1777
1841
|
+ '<button type="button" class="rmbtn" data-rbranch="'+r.id+'" title="base branch">'+ic('branch')+'</button>'
|
|
1778
1842
|
+ '<button type="button" class="rmbtn" data-rremove="'+r.id+'" title="remove">'+ic('x')+'</button>'
|
|
1779
|
-
+ '</span></
|
|
1843
|
+
+ '</span></div>';
|
|
1780
1844
|
}
|
|
1781
1845
|
} else {
|
|
1782
1846
|
// empty state — 첫 repo 추가 CTA
|
|
@@ -1804,6 +1868,12 @@ $('repoList').addEventListener('click', (e)=>{
|
|
|
1804
1868
|
const row = e.target.closest('.repo[data-repo]'); if(!row) return;
|
|
1805
1869
|
setScope(row.dataset.repo==='all' ? null : Number(row.dataset.repo));
|
|
1806
1870
|
});
|
|
1871
|
+
$('repoList').addEventListener('keydown', (e)=>{
|
|
1872
|
+
if (e.key!=='Enter' && e.key!==' ') return;
|
|
1873
|
+
const row = e.target.closest('.repo[data-repo]'); if(!row) return;
|
|
1874
|
+
e.preventDefault();
|
|
1875
|
+
setScope(row.dataset.repo==='all' ? null : Number(row.dataset.repo));
|
|
1876
|
+
});
|
|
1807
1877
|
$('captures').addEventListener('click', async (e)=>{
|
|
1808
1878
|
const b = e.target.closest('button[data-delcap]'); if(!b) return;
|
|
1809
1879
|
await fetch('/api/design/'+b.dataset.delcap,{method:'DELETE'});
|
|
@@ -2156,6 +2226,8 @@ $('selGo').addEventListener('click', async ()=>{
|
|
|
2156
2226
|
$('grid').addEventListener('click', async (e)=>{
|
|
2157
2227
|
const groom = e.target.closest('[data-groom]');
|
|
2158
2228
|
if (groom){ openRoom(Number(groom.dataset.groom)); return; }
|
|
2229
|
+
const gov = e.target.closest('[data-goverlap]');
|
|
2230
|
+
if (gov){ toggleOverlap(Number(gov.dataset.goverlap)); return; }
|
|
2159
2231
|
const fold = e.target.closest('[data-gfold]');
|
|
2160
2232
|
if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
|
|
2161
2233
|
const gsel = e.target.closest('[data-gsel]');
|
|
@@ -2355,24 +2427,52 @@ async function paintCompare(){
|
|
|
2355
2427
|
}
|
|
2356
2428
|
}
|
|
2357
2429
|
}
|
|
2430
|
+
// v5.1 step 2+3 — preview the land (target drift + conflict files) and warn at the decision point.
|
|
2431
|
+
async function driftNote(rid){
|
|
2432
|
+
try{
|
|
2433
|
+
const p = await (await fetch('/api/runs/'+rid+'/merge/preview?fetch=1')).json();
|
|
2434
|
+
if (!p || !p.target) return '';
|
|
2435
|
+
if (p.supported && !p.clean && (p.conflicts||[]).length){
|
|
2436
|
+
const files = p.conflicts.slice(0,4).join(', ') + (p.conflicts.length>4 ? ' +'+(p.conflicts.length-4)+' more' : '');
|
|
2437
|
+
return ' ⚠ landing on '+p.target+' would conflict in '+p.conflicts.length+' file'+(p.conflicts.length>1?'s':'')
|
|
2438
|
+
+' ('+files+'). Resolve on the branch first, or land (rebase) instead of a whole-branch merge.';
|
|
2439
|
+
}
|
|
2440
|
+
if ((p.behind||0) > 0) return ' ⚠ the base is '+p.behind+' commit'+(p.behind>1?'s':'')+' behind '+p.target
|
|
2441
|
+
+' — a whole-branch merge may conflict; landing (rebase onto '+p.target+') is safer.';
|
|
2442
|
+
if (p.supported && p.clean) return ' ✓ clean against '+p.target+'.';
|
|
2443
|
+
return '';
|
|
2444
|
+
}catch(e){ return ''; }
|
|
2445
|
+
}
|
|
2358
2446
|
$('cmpBody').addEventListener('click', async (e)=>{
|
|
2359
2447
|
const prBtn = e.target.closest('button[data-pr]');
|
|
2360
2448
|
if (prBtn){
|
|
2361
2449
|
const rid = Number(prBtn.dataset.pr);
|
|
2362
|
-
const yes = await confirmUI('
|
|
2363
|
-
{ sub: '
|
|
2450
|
+
const yes = await confirmUI('Land r'+rid+' as a pull request?',
|
|
2451
|
+
{ sub: 'Rebases this run onto the latest origin target, pushes it as coxpit/<task>-r'+rid+', and opens a PR against that target (needs gh signed in).'+(await driftNote(rid)), okLabel: 'Land · PR' });
|
|
2364
2452
|
if (!yes) return;
|
|
2365
2453
|
prBtn.disabled = true;
|
|
2366
2454
|
const res = await fetch('/api/runs/'+rid+'/pr',{method:'POST'});
|
|
2367
2455
|
const j = await res.json().catch(()=>({}));
|
|
2368
2456
|
if (res.ok){ toast('PR opened: '+j.url, 'ok'); await paintCompare(); hydrate(); }
|
|
2457
|
+
else if (j.conflict){
|
|
2458
|
+
const cf = (j.conflicts||[]);
|
|
2459
|
+
const go = await confirmUI('r'+rid+' conflicts with the target in '+cf.length+' file'+(cf.length>1?'s':'')+'.',
|
|
2460
|
+
{ sub: cf.slice(0,6).join(', ')+'. Let the agent resolve the markers, then land automatically — coxpit drives git, the agent only edits files.', okLabel: 'Agent · resolve & land' });
|
|
2461
|
+
if (go){
|
|
2462
|
+
const r2 = await fetch('/api/runs/'+rid+'/land/resolve',{method:'POST'});
|
|
2463
|
+
const j2 = await r2.json().catch(()=>({}));
|
|
2464
|
+
toast(r2.ok ? (j2.detail||'resolving…') : ('resolve: '+(j2.detail||r2.status)), r2.ok?'ok':'error');
|
|
2465
|
+
hydrate();
|
|
2466
|
+
}
|
|
2467
|
+
prBtn.disabled = false;
|
|
2468
|
+
}
|
|
2369
2469
|
else { toast('PR: '+(j.detail||res.status), 'error'); prBtn.disabled = false; }
|
|
2370
2470
|
return;
|
|
2371
2471
|
}
|
|
2372
2472
|
const btn = e.target.closest('button[data-merge]'); if(!btn) return;
|
|
2373
2473
|
const rid = Number(btn.dataset.merge);
|
|
2374
2474
|
const yes = await confirmUI('Merge r'+rid+' into the base branch?',
|
|
2375
|
-
{ sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
|
|
2475
|
+
{ sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.'+(await driftNote(rid)), okLabel: 'Merge' });
|
|
2376
2476
|
if (!yes) return;
|
|
2377
2477
|
btn.disabled = true;
|
|
2378
2478
|
const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
@@ -3034,7 +3134,7 @@ function setV(tab){
|
|
|
3034
3134
|
$('panelBench').hidden = tab!=='bench';
|
|
3035
3135
|
$('panelBench').style.display = tab==='bench' ? 'flex' : 'none';
|
|
3036
3136
|
// footer adapts: Task=Dry/Real·count·Run fleet · Goal=Dry/Real·Plan & run · Workbench=Open workbench only
|
|
3037
|
-
$('modeSeg').style.display = tab==='bench' ? 'none' : 'flex'; // workbench 는 에이전트 없음
|
|
3137
|
+
$('modeSeg').style.display = tab==='bench' ? 'none' : 'inline-flex'; // workbench 는 에이전트 없음
|
|
3038
3138
|
$('taskCountStep').style.display = tab==='task' ? 'flex' : 'none'; // count 는 Task 만(플래너·워크벤치는 1)
|
|
3039
3139
|
$('runFleetBtn').innerHTML = ic(L_ICON[tab]) + ' ' + L_LABEL[tab];
|
|
3040
3140
|
}
|
|
@@ -3178,18 +3278,15 @@ $('cntDown').addEventListener('click', ()=>stepCount(-1));
|
|
|
3178
3278
|
$('cntUp').addEventListener('click', ()=>stepCount(1));
|
|
3179
3279
|
$('taskCount').addEventListener('change', ()=>stepCount(0)); // clamp manual typing
|
|
3180
3280
|
|
|
3181
|
-
/* ── agent mode
|
|
3182
|
-
const segOpts = Array.from(document.querySelectorAll('#modeSeg .seg-opt'));
|
|
3281
|
+
/* ── agent mode switch (compact toggle mirroring hidden #taskReal) ── */
|
|
3183
3282
|
function setMode(real, persist){
|
|
3184
3283
|
$('taskReal').checked = real;
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
3189
|
-
}
|
|
3284
|
+
$('modeSeg').setAttribute('aria-checked', real ? 'true' : 'false');
|
|
3285
|
+
$('modeRealLbl').textContent = real ? 'Real agent' : 'Dry run';
|
|
3286
|
+
$('modeRealHint').textContent = real ? 'spends credits' : 'rehearsal';
|
|
3190
3287
|
if (persist) try { localStorage.setItem('coxpit.real', real ? '1' : '0'); } catch {}
|
|
3191
3288
|
}
|
|
3192
|
-
|
|
3289
|
+
$('modeSeg').addEventListener('click', ()=>setMode(!$('taskReal').checked, true));
|
|
3193
3290
|
let savedMode = null;
|
|
3194
3291
|
try { savedMode = localStorage.getItem('coxpit.real'); } catch {}
|
|
3195
3292
|
setMode(savedMode === '1', false);
|
package/src/orchestrator.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync } from 'node:fs';
|
|
|
5
5
|
import { mkdir, copyFile, readFile, writeFile, rm } from 'node:fs/promises';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import type { ChildProcess } from 'node:child_process';
|
|
8
|
-
import { eq } from 'drizzle-orm';
|
|
8
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
9
9
|
import { config } from './config';
|
|
10
10
|
import { db } from './db';
|
|
11
11
|
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups } from './db/schema';
|
|
@@ -39,6 +39,26 @@ export function parseOutputs(raw: string | null | undefined): OutputType[] {
|
|
|
39
39
|
try { return normalizeOutputs(JSON.parse(raw)); } catch { return []; }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* v5.1 A2 — a run that settled clean but did nothing it was asked to.
|
|
44
|
+
* Deterministic signal (primary): status 'done' with zero files changed.
|
|
45
|
+
* Gated on intent so an answer-only task legitimately changing nothing stays quiet.
|
|
46
|
+
* The 'blocked' upgrade is best-effort natural-language matching on the final message —
|
|
47
|
+
* no provider emits a structured "needs approval" event, so never depend on it.
|
|
48
|
+
*/
|
|
49
|
+
export function noopSignal(
|
|
50
|
+
status: string,
|
|
51
|
+
filesChanged: number,
|
|
52
|
+
exitSummary: string | null | undefined,
|
|
53
|
+
taskOutputs: string | null | undefined,
|
|
54
|
+
): { noop: boolean; reason: 'blocked' | 'no-changes' | null } {
|
|
55
|
+
if (status !== 'done' || (filesChanged ?? 0) > 0) return { noop: false, reason: null };
|
|
56
|
+
const declared = parseOutputs(taskOutputs);
|
|
57
|
+
if (declared.length > 0 && declared.every((t) => t === 'answer')) return { noop: false, reason: null };
|
|
58
|
+
const blocked = /\b(approval|permission|not allowed|need(s)?\s+(explicit\s+)?approv|explicit(ly)?\s+approv|allow me to)\b/i.test(exitSummary ?? '');
|
|
59
|
+
return { noop: true, reason: blocked ? 'blocked' : 'no-changes' };
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
/** 프롬프트에 붙는 Deliverables 블록(A3). declared 는 비어있지 않다. */
|
|
43
63
|
function deliverablesNote(declared: OutputType[]): string {
|
|
44
64
|
const human: Record<OutputType, string> = {
|
|
@@ -1219,7 +1239,19 @@ export async function exportRun(runId: number, destIn?: string): Promise<{ ok: b
|
|
|
1219
1239
|
* PR 모드 — run 브랜치를 origin 에 push 하고 gh 로 pull request 를 연다.
|
|
1220
1240
|
* 팀 repo·리뷰 흐름용: 로컬 merge 대신 PR 로 결과를 보낸다.
|
|
1221
1241
|
*/
|
|
1222
|
-
|
|
1242
|
+
/** Product-appropriate PR branch name: coxpit/<task-slug>-r<id> — meaningful, unique, namespaced. */
|
|
1243
|
+
function slugify(s: string): string {
|
|
1244
|
+
return (s || 'run').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'run';
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* v5.1 step 4 — land a run: origin-aware. Resolve the target (origin/<x>, may differ from the
|
|
1249
|
+
* local base), preview conflicts, then squash the run's net diff onto a fresh branch AT the
|
|
1250
|
+
* target (avoids the base-merge-commit drag, C6), push under the product name, and open a PR
|
|
1251
|
+
* against the target branch. On conflict returns { conflict } for the integration loop (step 5).
|
|
1252
|
+
* Falls back to the legacy push-branch/PR-against-base when the base has no upstream.
|
|
1253
|
+
*/
|
|
1254
|
+
export async function prRun(runId: number): Promise<{ ok: boolean; detail: string; url?: string; conflict?: boolean; conflicts?: string[] }> {
|
|
1223
1255
|
const ctx = await loadContext(runId);
|
|
1224
1256
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
1225
1257
|
const run = rr[0];
|
|
@@ -1227,37 +1259,141 @@ export async function prRun(runId: number): Promise<{ ok: boolean; detail: strin
|
|
|
1227
1259
|
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
|
|
1228
1260
|
if (!['done', 'failed', 'stopped', 'open'].includes(run.status)) return { ok: false, detail: `cannot open a PR from a '${run.status}' run` };
|
|
1229
1261
|
const wt = shq(run.worktreePath);
|
|
1262
|
+
const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
|
|
1230
1263
|
|
|
1231
|
-
// 0)
|
|
1264
|
+
// 0) preconditions: origin remote + gh CLI
|
|
1232
1265
|
const pre = await runShellOn(ctx.machine,
|
|
1233
1266
|
`cd ${wt} && { git remote get-url origin >/dev/null 2>&1 && echo R1 || echo R0; } && { command -v gh >/dev/null 2>&1 && echo G1 || echo G0; }`, 10000);
|
|
1234
1267
|
if (!pre.stdout.includes('R1')) return { ok: false, detail: 'no origin remote on this repo' };
|
|
1235
1268
|
if (!pre.stdout.includes('G1')) return { ok: false, detail: 'GitHub CLI (gh) not found on the machine' };
|
|
1236
1269
|
|
|
1237
|
-
// 1) worktree
|
|
1238
|
-
const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
|
|
1270
|
+
// 1) commit worktree changes
|
|
1239
1271
|
const c1 = await runShellOn(ctx.machine,
|
|
1240
1272
|
`cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`, 20000);
|
|
1241
1273
|
if (!c1.ok) return { ok: false, detail: 'worktree commit failed: ' + (c1.stderr || c1.stdout).trim().slice(0, 300) };
|
|
1242
1274
|
|
|
1243
|
-
// 2) push
|
|
1244
|
-
const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(run.branch)} 2>&1`, 60000);
|
|
1245
|
-
if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
|
|
1246
|
-
|
|
1247
|
-
// 3) PR 생성 (동일 브랜치 PR 이 이미 있으면 그 URL 재사용)
|
|
1248
1275
|
const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
|
|
1249
1276
|
const title = `${tr[0]?.title ?? 'coxpit run'} (r${runId})`;
|
|
1250
1277
|
const body = (run.exitSummary ? run.exitSummary + '\n\n' : '') + '🤖 Opened from a coxpit agent run';
|
|
1278
|
+
|
|
1279
|
+
// 2) resolve the land target (fresh) — origin-aware path when the base tracks a remote
|
|
1280
|
+
const lt = await landTarget(runId, { fetch: true });
|
|
1281
|
+
const target = lt.target;
|
|
1282
|
+
if (target) {
|
|
1283
|
+
const pv = await mergePreview(runId, { target });
|
|
1284
|
+
if (pv.supported && !pv.clean && pv.conflicts.length) {
|
|
1285
|
+
return { ok: false, conflict: true, conflicts: pv.conflicts,
|
|
1286
|
+
detail: `would conflict on ${target} in ${pv.conflicts.length} file(s): ${pv.conflicts.slice(0, 6).join(', ')} — resolve on the branch first, then land.` };
|
|
1287
|
+
}
|
|
1288
|
+
const targetBranch = target.replace(/^[^/]+\//, ''); // origin/develop -> develop
|
|
1289
|
+
const landBranch = `coxpit/${slugify(tr[0]?.title ?? 'run')}-r${runId}`;
|
|
1290
|
+
const range = shq(ctx.baseBranch + '...' + run.branch);
|
|
1291
|
+
// squash the run's net diff onto a fresh branch at the target (3-way apply; clean per preview)
|
|
1292
|
+
const land = await runShellOn(ctx.machine,
|
|
1293
|
+
`cd ${wt} && git checkout -B ${shq(landBranch)} ${shq(target)} 2>&1 && ` +
|
|
1294
|
+
`git diff ${range} | git apply --3way --index - 2>&1 && ` +
|
|
1295
|
+
`(git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(title)}) 2>&1`, 60000);
|
|
1296
|
+
if (!land.ok) return { ok: false, detail: `land (onto ${target}) failed: ` + (land.stderr || land.stdout).trim().slice(0, 300) };
|
|
1297
|
+
const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(landBranch)} 2>&1`, 60000);
|
|
1298
|
+
if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
|
|
1299
|
+
const pr = await runShellOn(ctx.machine,
|
|
1300
|
+
`cd ${wt} && gh pr create -B ${shq(targetBranch)} -H ${shq(landBranch)} -t ${shq(title)} -b ${shq(body)} 2>&1 || true`, 60000);
|
|
1301
|
+
const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
|
|
1302
|
+
if (!m) return { ok: false, detail: 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 300) };
|
|
1303
|
+
await setRun(runId, { prUrl: m[0] });
|
|
1304
|
+
await recordEvent(runId, 'pr', `${m[0]} (landed on ${targetBranch})`);
|
|
1305
|
+
return { ok: true, detail: `landed on ${targetBranch} · PR opened`, url: m[0] };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// fallback: no upstream target — legacy push-branch / PR-against-base
|
|
1309
|
+
const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(run.branch)} 2>&1`, 60000);
|
|
1310
|
+
if (!push.ok) return { ok: false, detail: 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 300) };
|
|
1251
1311
|
const pr = await runShellOn(ctx.machine,
|
|
1252
1312
|
`cd ${wt} && gh pr create -B ${shq(ctx.baseBranch)} -H ${shq(run.branch)} -t ${shq(title)} -b ${shq(body)} 2>&1 || true`, 60000);
|
|
1253
1313
|
const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
|
|
1254
1314
|
if (!m) return { ok: false, detail: 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 300) };
|
|
1255
|
-
|
|
1256
1315
|
await setRun(runId, { prUrl: m[0] });
|
|
1257
1316
|
await recordEvent(runId, 'pr', m[0]);
|
|
1258
1317
|
return { ok: true, detail: 'pull request opened', url: m[0] };
|
|
1259
1318
|
}
|
|
1260
1319
|
|
|
1320
|
+
// v5.1 step 5 — the integration loop. coxpit owns git; the agent only edits conflict markers.
|
|
1321
|
+
type PendingLand = { target: string; targetBranch: string; landBranch: string; title: string; body: string };
|
|
1322
|
+
const pendingLand = new Map<number, PendingLand>();
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Start resolving a conflicting land in-app: coxpit stages the run's work, checks out a fresh
|
|
1326
|
+
* branch at the target and 3-way-applies the net diff (leaving conflict markers), then RESUMES
|
|
1327
|
+
* the run's own agent scoped to editing those markers only (no git — that would hit the sandbox
|
|
1328
|
+
* wall that blocked the merge in the first place). When the agent settles, finalizeLand() commits,
|
|
1329
|
+
* pushes and opens the PR. The agent carries the original context, so it resolves knowingly.
|
|
1330
|
+
*/
|
|
1331
|
+
export async function startLandResolve(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
1332
|
+
const ctx = await loadContext(runId);
|
|
1333
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
1334
|
+
const run = rr[0];
|
|
1335
|
+
if (!ctx || !run || !run.worktreePath || !run.branch) return { ok: false, detail: 'no worktree/branch' };
|
|
1336
|
+
if (liveChildren.has(runId)) return { ok: false, detail: 'still running — stop it first' };
|
|
1337
|
+
if (!run.sessionId) return { ok: false, detail: 'no agent session — dry runs cannot auto-resolve; open the workbench' };
|
|
1338
|
+
const wt = shq(run.worktreePath);
|
|
1339
|
+
const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
|
|
1340
|
+
await runShellOn(ctx.machine, `cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(`coxpit r${runId}: agent changes`)})`, 20000);
|
|
1341
|
+
const lt = await landTarget(runId, { fetch: true });
|
|
1342
|
+
const target = lt.target;
|
|
1343
|
+
if (!target) return { ok: false, detail: 'no upstream target to land on' };
|
|
1344
|
+
const targetBranch = target.replace(/^[^/]+\//, '');
|
|
1345
|
+
const tr = await db.select().from(tasks).where(eq(tasks.id, run.taskId)).limit(1);
|
|
1346
|
+
const title = `${tr[0]?.title ?? 'coxpit run'} (r${runId})`;
|
|
1347
|
+
const body = (run.exitSummary ? run.exitSummary + '\n\n' : '') + '🤖 Landed from a coxpit agent run (conflicts resolved by the agent)';
|
|
1348
|
+
const landBranch = `coxpit/${slugify(tr[0]?.title ?? 'run')}-r${runId}`;
|
|
1349
|
+
const range = shq(ctx.baseBranch + '...' + run.branch);
|
|
1350
|
+
const prep = await runShellOn(ctx.machine,
|
|
1351
|
+
`cd ${wt} && git checkout -B ${shq(landBranch)} ${shq(target)} 2>&1 && { git diff ${range} | git apply --3way --index - 2>&1 || true; } && git rev-parse --abbrev-ref HEAD`, 60000);
|
|
1352
|
+
if (!prep.stdout.trim().endsWith(landBranch)) return { ok: false, detail: 'could not prepare land branch: ' + prep.stdout.trim().slice(-200) };
|
|
1353
|
+
pendingLand.set(runId, { target, targetBranch, landBranch, title, body });
|
|
1354
|
+
const prompt = `You are on branch ${landBranch}. Landing your change onto ${target} produced git conflict markers (<<<<<<<, =======, >>>>>>>) in one or more files. Resolve EVERY conflict marker by editing the files to the correct merged result — keep both your change and the target's changes where each belongs. Do NOT run any git commands (no add/commit/rebase/merge) — only edit the files. When every marker is gone, end your turn.`;
|
|
1355
|
+
await setRun(runId, { status: 'running', endedAt: null });
|
|
1356
|
+
await recordEvent(runId, 'integrate', `resolving conflicts to land on ${targetBranch} — the agent is editing markers`);
|
|
1357
|
+
const provider = getProvider(ctx.agent);
|
|
1358
|
+
const isRemote = ctx.machine.kind !== 'local' && ctx.machine.address !== '';
|
|
1359
|
+
const pidPrefix = isRemote ? `printf '%s' "$$" > .coxpit-agent.pid && ` : '';
|
|
1360
|
+
const resume = provider.resumeCmd(run.sessionId, prompt, run.model || undefined);
|
|
1361
|
+
const cmd = `cd ${shq(run.worktreePath)} && ${pidPrefix}{ ${resume}; }`;
|
|
1362
|
+
void runAgentChild(runId, ctx.machine, run.worktreePath, cmd, provider).then(() => finalizeLand(runId)).catch(() => { pendingLand.delete(runId); });
|
|
1363
|
+
return { ok: true, detail: `integrating — the agent is resolving conflicts; it lands on ${targetBranch} automatically when clean` };
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** After the resolve agent settles: verify no markers remain, then commit + push + open the PR. */
|
|
1367
|
+
async function finalizeLand(runId: number): Promise<void> {
|
|
1368
|
+
const pend = pendingLand.get(runId);
|
|
1369
|
+
if (!pend) return;
|
|
1370
|
+
pendingLand.delete(runId);
|
|
1371
|
+
const ctx = await loadContext(runId);
|
|
1372
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
1373
|
+
const run = rr[0];
|
|
1374
|
+
if (!ctx || !run || !run.worktreePath) return;
|
|
1375
|
+
const wt = shq(run.worktreePath);
|
|
1376
|
+
const ident = `-c user.name='coxpit' -c user.email='coxpit@local'`;
|
|
1377
|
+
const chk = await runShellOn(ctx.machine,
|
|
1378
|
+
`cd ${wt} && git diff --name-only | tr '\\n' '\\0' | xargs -0 -r grep -lE '^(<<<<<<< |>>>>>>> )' 2>/dev/null | head -20`, 15000);
|
|
1379
|
+
const leftover = chk.stdout.trim();
|
|
1380
|
+
if (leftover) {
|
|
1381
|
+
await recordEvent(runId, 'error', 'conflict markers still present after the agent pass — resolve the rest (attach the terminal) or run resolve again:\n' + leftover.slice(0, 300));
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const c = await runShellOn(ctx.machine,
|
|
1385
|
+
`cd ${wt} && git add -A && (git diff --cached --quiet || git ${ident} -c commit.gpgsign=false commit -m ${shq(pend.title)}) 2>&1`, 30000);
|
|
1386
|
+
if (!c.ok) { await recordEvent(runId, 'error', 'land commit failed: ' + (c.stderr || c.stdout).trim().slice(0, 200)); return; }
|
|
1387
|
+
const push = await runShellOn(ctx.machine, `cd ${wt} && git push -u origin ${shq(pend.landBranch)} 2>&1`, 60000);
|
|
1388
|
+
if (!push.ok) { await recordEvent(runId, 'error', 'push failed: ' + (push.stderr || push.stdout).trim().slice(0, 200)); return; }
|
|
1389
|
+
const pr = await runShellOn(ctx.machine,
|
|
1390
|
+
`cd ${wt} && gh pr create -B ${shq(pend.targetBranch)} -H ${shq(pend.landBranch)} -t ${shq(pend.title)} -b ${shq(pend.body)} 2>&1 || true`, 60000);
|
|
1391
|
+
const m = (pr.stdout + pr.stderr).match(/https:\/\/github\.com\/\S+\/pull\/\d+/);
|
|
1392
|
+
if (!m) { await recordEvent(runId, 'error', 'gh pr create failed: ' + (pr.stdout || pr.stderr).trim().slice(0, 200)); return; }
|
|
1393
|
+
await setRun(runId, { prUrl: m[0] });
|
|
1394
|
+
await recordEvent(runId, 'pr', `${m[0]} (landed on ${pend.targetBranch} after resolve)`);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1261
1397
|
/**
|
|
1262
1398
|
* worktree/브랜치/tmux 정리(태스크 종료·run 폐기 시).
|
|
1263
1399
|
*/
|
|
@@ -1293,6 +1429,139 @@ export async function taskCloseRisk(taskId: number): Promise<Array<{ runId: numb
|
|
|
1293
1429
|
return atRisk;
|
|
1294
1430
|
}
|
|
1295
1431
|
|
|
1432
|
+
/**
|
|
1433
|
+
* v5.1 A3 — sibling overlap within a group: which files two or more sibling runs both touch,
|
|
1434
|
+
* and a suggested land order (fewest contended files first). Read-only; on-demand (git per run),
|
|
1435
|
+
* never on the fleet-poll path. Uses `diff --name-only base...branch` (merge-base) = each run's
|
|
1436
|
+
* OWN change set, so base drift doesn't inflate it.
|
|
1437
|
+
*/
|
|
1438
|
+
export async function groupOverlap(groupId: number): Promise<{
|
|
1439
|
+
runs: Array<{ runId: number; files: string[] }>;
|
|
1440
|
+
contended: Array<{ path: string; runIds: number[] }>;
|
|
1441
|
+
order: number[];
|
|
1442
|
+
}> {
|
|
1443
|
+
const taskRows = await db.select().from(tasks).where(eq(tasks.groupId, groupId));
|
|
1444
|
+
const taskIds = taskRows.map((t) => t.id);
|
|
1445
|
+
if (!taskIds.length) return { runs: [], contended: [], order: [] };
|
|
1446
|
+
const rns = await db.select().from(agentRuns).where(inArray(agentRuns.taskId, taskIds));
|
|
1447
|
+
const perRun: Array<{ runId: number; files: string[] }> = [];
|
|
1448
|
+
for (const r of rns) {
|
|
1449
|
+
if (!r.branch || !r.worktreePath || r.status === 'merged') continue;
|
|
1450
|
+
const ctx = await loadContext(r.id);
|
|
1451
|
+
if (!ctx) continue;
|
|
1452
|
+
const out = await runShellOn(
|
|
1453
|
+
ctx.machine,
|
|
1454
|
+
`git -C ${shq(ctx.repoPath)} diff --name-only ${shq(ctx.baseBranch)}...${shq(r.branch)}`,
|
|
1455
|
+
10000,
|
|
1456
|
+
);
|
|
1457
|
+
const files = out.ok ? out.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [];
|
|
1458
|
+
perRun.push({ runId: r.id, files });
|
|
1459
|
+
}
|
|
1460
|
+
const byFile = new Map<string, number[]>();
|
|
1461
|
+
for (const pr of perRun) for (const f of pr.files) {
|
|
1462
|
+
const a = byFile.get(f) ?? []; a.push(pr.runId); byFile.set(f, a);
|
|
1463
|
+
}
|
|
1464
|
+
const contended = [...byFile.entries()]
|
|
1465
|
+
.filter(([, ids]) => ids.length > 1)
|
|
1466
|
+
.map(([path, runIds]) => ({ path, runIds }))
|
|
1467
|
+
.sort((a, b) => b.runIds.length - a.runIds.length || a.path.localeCompare(b.path));
|
|
1468
|
+
const contendedSet = new Set(contended.map((c) => c.path));
|
|
1469
|
+
const contendedCount = (pr: { files: string[] }) => pr.files.filter((f) => contendedSet.has(f)).length;
|
|
1470
|
+
const order = perRun.slice()
|
|
1471
|
+
.sort((a, b) => contendedCount(a) - contendedCount(b) || a.runId - b.runId)
|
|
1472
|
+
.map((p) => p.runId);
|
|
1473
|
+
return { runs: perRun, contended, order };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* v5.1 Part C foundation — resolve the land *target* for a run and measure base drift.
|
|
1478
|
+
* The target is NOT assumed to be the base: default = the base branch's upstream
|
|
1479
|
+
* (`<base>@{u}`, e.g. main→origin/main), else `origin/<base>` if it exists, else null
|
|
1480
|
+
* (the caller must pick one). With { fetch }, refresh the remote first so drift is current.
|
|
1481
|
+
* ahead = commits the local base has that the target lacks; behind = commits the target has
|
|
1482
|
+
* that the local base lacks (the drift that makes a whole-branch merge explode).
|
|
1483
|
+
*/
|
|
1484
|
+
export async function landTarget(runId: number, opts: { fetch?: boolean } = {}): Promise<{
|
|
1485
|
+
base: string; target: string | null; remote: string | null;
|
|
1486
|
+
ahead: number; behind: number; fetched: boolean; detail?: string;
|
|
1487
|
+
}> {
|
|
1488
|
+
const ctx = await loadContext(runId);
|
|
1489
|
+
if (!ctx) return { base: '', target: null, remote: null, ahead: 0, behind: 0, fetched: false, detail: 'no context' };
|
|
1490
|
+
const repo = shq(ctx.repoPath);
|
|
1491
|
+
const base = ctx.baseBranch;
|
|
1492
|
+
const up = await runShellOn(ctx.machine, `git -C ${repo} rev-parse --abbrev-ref ${shq(base + '@{u}')} 2>/dev/null`, 8000);
|
|
1493
|
+
let target: string | null = up.ok && up.stdout.trim() ? up.stdout.trim() : null;
|
|
1494
|
+
let remote: string | null = target ? (target.split('/')[0] ?? null) : null;
|
|
1495
|
+
if (!target) {
|
|
1496
|
+
const has = await runShellOn(ctx.machine, `git -C ${repo} rev-parse --verify --quiet ${shq('origin/' + base)}`, 8000);
|
|
1497
|
+
if (has.ok && has.stdout.trim()) { target = 'origin/' + base; remote = 'origin'; }
|
|
1498
|
+
}
|
|
1499
|
+
if (!target) return { base, target: null, remote: null, ahead: 0, behind: 0, fetched: false, detail: 'no upstream — pick a target' };
|
|
1500
|
+
let fetched = false;
|
|
1501
|
+
if (opts.fetch && remote) {
|
|
1502
|
+
const f = await runShellOn(ctx.machine, `git -C ${repo} fetch ${shq(remote)} 2>&1`, 30000);
|
|
1503
|
+
fetched = f.ok;
|
|
1504
|
+
}
|
|
1505
|
+
const rl = await runShellOn(ctx.machine, `git -C ${repo} rev-list --left-right --count ${shq(base + '...' + target)}`, 10000);
|
|
1506
|
+
let ahead = 0, behind = 0;
|
|
1507
|
+
if (rl.ok) { const [a = '0', b = '0'] = rl.stdout.trim().split(/\s+/); ahead = parseInt(a, 10) || 0; behind = parseInt(b, 10) || 0; }
|
|
1508
|
+
return { base, target, remote, ahead, behind, fetched };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* v5.1 A1 (step 3) — conflict preview: what WOULD conflict if this run landed on the target,
|
|
1513
|
+
* computed with `git merge-tree --write-tree` (git ≥ 2.38) — no working tree, no commit, no
|
|
1514
|
+
* side effects. Previews against the land *target* (origin/<x>), not the local base (a run never
|
|
1515
|
+
* conflicts with the base it descends from). Folds in the drift (ahead/behind) from landTarget so
|
|
1516
|
+
* one call answers "is landing safe, and if not, which files".
|
|
1517
|
+
*
|
|
1518
|
+
* merge-tree output: line 0 = tree OID; on conflict, the conflicted paths follow until a blank
|
|
1519
|
+
* line (informational messages after). Exit 0 = clean, non-zero = conflicts.
|
|
1520
|
+
*/
|
|
1521
|
+
export async function mergePreview(runId: number, opts: { fetch?: boolean; target?: string } = {}): Promise<{
|
|
1522
|
+
supported: boolean; clean: boolean; conflicts: string[]; target: string | null;
|
|
1523
|
+
ahead: number; behind: number; detail?: string;
|
|
1524
|
+
}> {
|
|
1525
|
+
const ctx = await loadContext(runId);
|
|
1526
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
1527
|
+
const run = rr[0];
|
|
1528
|
+
if (!ctx || !run || !run.branch) return { supported: true, clean: false, conflicts: [], target: null, ahead: 0, behind: 0, detail: 'no branch' };
|
|
1529
|
+
const lt = await landTarget(runId, { fetch: opts.fetch });
|
|
1530
|
+
const target = opts.target ?? lt.target;
|
|
1531
|
+
if (!target) return { supported: true, clean: false, conflicts: [], target: null, ahead: lt.ahead, behind: lt.behind, detail: 'no target — pick one' };
|
|
1532
|
+
const repo = shq(ctx.repoPath);
|
|
1533
|
+
// The agent edits files but does NOT commit (acceptEdits/workspace-write allow edits, not git),
|
|
1534
|
+
// so run.branch's tip lags the worktree. Land commits first — so preview the state land will
|
|
1535
|
+
// actually see: `git stash create` mints a commit of the working tree (tracked mods) without
|
|
1536
|
+
// touching anything, sharing the object store so merge-tree in the repo can reference it.
|
|
1537
|
+
let tip = run.branch;
|
|
1538
|
+
if (run.worktreePath) {
|
|
1539
|
+
const st = await runShellOn(ctx.machine, `git -C ${shq(run.worktreePath)} stash create 2>/dev/null`, 10000);
|
|
1540
|
+
const stashCommit = st.ok ? st.stdout.trim() : '';
|
|
1541
|
+
if (/^[0-9a-f]{7,40}$/.test(stashCommit)) tip = stashCommit;
|
|
1542
|
+
}
|
|
1543
|
+
const mt = await runShellOn(
|
|
1544
|
+
ctx.machine,
|
|
1545
|
+
`git -C ${repo} merge-tree --write-tree --name-only ${shq(target)} ${shq(tip)} 2>&1; echo "EXIT=$?"`,
|
|
1546
|
+
20000,
|
|
1547
|
+
);
|
|
1548
|
+
const raw = mt.stdout;
|
|
1549
|
+
if (/usage: git merge-tree|unknown option|error: unknown/i.test(raw)) {
|
|
1550
|
+
return { supported: false, clean: false, conflicts: [], target, ahead: lt.ahead, behind: lt.behind, detail: 'git >= 2.38 required for conflict preview' };
|
|
1551
|
+
}
|
|
1552
|
+
const m = raw.match(/EXIT=(\d+)\s*$/);
|
|
1553
|
+
const code = m ? parseInt(m[1] ?? '1', 10) : 1;
|
|
1554
|
+
const body = raw.replace(/\n?EXIT=\d+\s*$/, '');
|
|
1555
|
+
const lines = body.split('\n');
|
|
1556
|
+
const conflicts: string[] = [];
|
|
1557
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1558
|
+
if (lines[i]?.trim() === '') break;
|
|
1559
|
+
const p = lines[i]?.trim();
|
|
1560
|
+
if (p) conflicts.push(p);
|
|
1561
|
+
}
|
|
1562
|
+
return { supported: true, clean: code === 0, conflicts, target, ahead: lt.ahead, behind: lt.behind };
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1296
1565
|
export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail: string }> {
|
|
1297
1566
|
const ctx = await loadContext(runId);
|
|
1298
1567
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
package/src/server.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { db } from './db';
|
|
|
18
18
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
|
|
19
19
|
import { BOOKMARKLET_JS } from './design';
|
|
20
20
|
import { runShellOn, shq } from './exec';
|
|
21
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees } from './orchestrator';
|
|
21
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve } from './orchestrator';
|
|
22
22
|
import { openTerm } from './term';
|
|
23
23
|
import { addSink, removeSink, broadcast } from './hub';
|
|
24
24
|
import { getProvider, listProviders } from './providers';
|
|
@@ -328,9 +328,13 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
328
328
|
arr.push({ kind: e.kind, payload: e.payload });
|
|
329
329
|
byRun.set(e.runId, arr);
|
|
330
330
|
}
|
|
331
|
+
const taskOut = new Map(ts.map((t) => [t.id, t.outputs]));
|
|
331
332
|
return {
|
|
332
333
|
machines: ms, repos: rs, tasks: ts, captures: dcs, groups: gs,
|
|
333
|
-
runs: rns.map((r) =>
|
|
334
|
+
runs: rns.map((r) => {
|
|
335
|
+
const sig = noopSignal(r.status, r.filesChanged, r.exitSummary, taskOut.get(r.taskId) ?? '[]');
|
|
336
|
+
return { ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP), noop: sig.noop, noopReason: sig.reason };
|
|
337
|
+
}),
|
|
334
338
|
counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
|
|
335
339
|
// 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
|
|
336
340
|
// authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
|
|
@@ -859,6 +863,24 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
859
863
|
return res;
|
|
860
864
|
});
|
|
861
865
|
|
|
866
|
+
// v5.1 Part C foundation — resolve the land target + base drift for a run.
|
|
867
|
+
// ?fetch=1 refreshes the remote first (network) so ahead/behind are current.
|
|
868
|
+
app.get('/api/runs/:id/land-target', async (req, reply) => {
|
|
869
|
+
const id = Number((req.params as { id: string }).id);
|
|
870
|
+
if (!Number.isFinite(id)) return reply.code(400).send({ error: 'bad id' });
|
|
871
|
+
const doFetch = ((req.query ?? {}) as { fetch?: string }).fetch === '1';
|
|
872
|
+
return await landTarget(id, { fetch: doFetch });
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// v5.1 A1 — conflict preview: what would conflict if this run landed on the target (merge-tree,
|
|
876
|
+
// read-only). ?fetch=1 refreshes origin first; ?target=origin/x overrides the resolved target.
|
|
877
|
+
app.get('/api/runs/:id/merge/preview', async (req, reply) => {
|
|
878
|
+
const id = Number((req.params as { id: string }).id);
|
|
879
|
+
if (!Number.isFinite(id)) return reply.code(400).send({ error: 'bad id' });
|
|
880
|
+
const q = (req.query ?? {}) as { fetch?: string; target?: string };
|
|
881
|
+
return await mergePreview(id, { fetch: q.fetch === '1', target: q.target && q.target.trim() ? q.target.trim() : undefined });
|
|
882
|
+
});
|
|
883
|
+
|
|
862
884
|
// 승자 run 머지 — run 브랜치를 repo 기본 브랜치로.
|
|
863
885
|
app.post('/api/runs/:id/merge', async (req, reply) => {
|
|
864
886
|
const id = Number((req.params as { id: string }).id);
|
|
@@ -961,6 +983,14 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
961
983
|
};
|
|
962
984
|
});
|
|
963
985
|
|
|
986
|
+
// v5.1 A3 — sibling overlap: which files 2+ runs in the group both touch + suggested land order.
|
|
987
|
+
// Read-only, on-demand (shells git per run) — never on the fleet-poll path.
|
|
988
|
+
app.get('/api/groups/:id/overlap', async (req, reply) => {
|
|
989
|
+
const id = Number((req.params as { id: string }).id);
|
|
990
|
+
if (!Number.isFinite(id)) return reply.code(400).send({ error: 'bad id' });
|
|
991
|
+
return await groupOverlap(id);
|
|
992
|
+
});
|
|
993
|
+
|
|
964
994
|
// B2 — "+ New attempt": 그룹에 새 시도(들)를 발사. repo 는 그룹의 기존 태스크에서 상속.
|
|
965
995
|
app.post('/api/groups/:id/spawn', async (req, reply) => {
|
|
966
996
|
const id = Number((req.params as { id: string }).id);
|
|
@@ -1061,6 +1091,17 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1061
1091
|
return res;
|
|
1062
1092
|
});
|
|
1063
1093
|
|
|
1094
|
+
// v5.1 step 5 — resolve a conflicting land in-app: coxpit prepares the conflict state, the run's
|
|
1095
|
+
// own agent edits the markers, then it commits/pushes/PRs automatically when clean.
|
|
1096
|
+
app.post('/api/runs/:id/land/resolve', async (req, reply) => {
|
|
1097
|
+
const id = Number((req.params as { id: string }).id);
|
|
1098
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1099
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1100
|
+
const res = await startLandResolve(id);
|
|
1101
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
1102
|
+
return res;
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1064
1105
|
// 실행 중 run 중지(SIGTERM) — close 핸들러가 stopped 로 봉인.
|
|
1065
1106
|
app.post('/api/runs/:id/stop', async (req, reply) => {
|
|
1066
1107
|
const id = Number((req.params as { id: string }).id);
|