dw-kit 1.9.2 → 1.9.3
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 +150 -121
- package/README.vi.md +230 -0
- package/package.json +4 -1
- package/src/commands/voice.mjs +431 -2
- package/src/lib/board-data.mjs +23 -57
- package/src/lib/goal-driver.mjs +312 -0
- package/src/lib/goal-progress.mjs +193 -0
- package/src/lib/process-kill.mjs +77 -0
- package/src/lib/task-md-utils.mjs +78 -0
package/src/commands/voice.mjs
CHANGED
|
@@ -83,6 +83,12 @@ import { buildSessionTreeData } from '../lib/session-tree.mjs';
|
|
|
83
83
|
import { startDebate, resolveDebateRoster } from '../lib/debate.mjs';
|
|
84
84
|
import { createSseBroker } from '../lib/sse-broker.mjs';
|
|
85
85
|
import { buildBoardData } from '../lib/board-data.mjs';
|
|
86
|
+
import {
|
|
87
|
+
claimGoal, releaseGoal, attachSessionId, buildDriverPrompt, parseBudget, readClaim, listClaims,
|
|
88
|
+
} from '../lib/goal-driver.mjs';
|
|
89
|
+
import { startProgressWatcher, stopProgressWatcher } from '../lib/goal-progress.mjs';
|
|
90
|
+
import { logGoalEvent } from '../lib/goal-events.mjs';
|
|
91
|
+
import { killProcessTree } from '../lib/process-kill.mjs';
|
|
86
92
|
|
|
87
93
|
const CACHE_DIR = '.dw/cache';
|
|
88
94
|
const VOICE_TOKEN_PATH = '.dw/cache/voice.token';
|
|
@@ -989,6 +995,50 @@ if (document.readyState === 'loading') {
|
|
|
989
995
|
</body></html>`;
|
|
990
996
|
}
|
|
991
997
|
|
|
998
|
+
// ─ goal-driver-mvp ST-8: claim reconcile + tree-kill ─
|
|
999
|
+
//
|
|
1000
|
+
// Reconcile = sweep `.dw/cache/goal-claims/*.json`, drop any claim whose
|
|
1001
|
+
// linked session has terminated (status in exited/completed/failed/stopped
|
|
1002
|
+
// OR pid is dead). Without this, an agent that finishes cleanly leaves
|
|
1003
|
+
// the "Running…" pill stuck on the board until expires_at TTL fires.
|
|
1004
|
+
// Runs lazily on each /voice/board fetch + at the top of /voice/goal-start
|
|
1005
|
+
// so re-clicking Start always finds a fresh slot.
|
|
1006
|
+
//
|
|
1007
|
+
// Returns { released: string[] } so the caller can log how many stale
|
|
1008
|
+
// claims were collected on this pass.
|
|
1009
|
+
function reconcileGoalDrivers(rootDir) {
|
|
1010
|
+
const out = { released: [] };
|
|
1011
|
+
let claims;
|
|
1012
|
+
try { claims = listClaims(rootDir); }
|
|
1013
|
+
catch { return out; }
|
|
1014
|
+
const GRACE_MS = 5_000; // don't release a claim younger than this (spawn race)
|
|
1015
|
+
const now = Date.now();
|
|
1016
|
+
for (const claim of claims) {
|
|
1017
|
+
if (!claim.session_id) continue;
|
|
1018
|
+
let s;
|
|
1019
|
+
try { s = getSession(claim.session_id, rootDir); }
|
|
1020
|
+
catch { continue; }
|
|
1021
|
+
if (!s) continue;
|
|
1022
|
+
const terminal = ['exited', 'completed', 'failed', 'stopped'].includes(s.status);
|
|
1023
|
+
const pidDead = s.pid ? !isAlive(s.pid) : true;
|
|
1024
|
+
if (!(terminal && pidDead)) continue;
|
|
1025
|
+
const claimedMs = Date.parse(claim.claimed_at);
|
|
1026
|
+
if (Number.isFinite(claimedMs) && (now - claimedMs) < GRACE_MS) continue;
|
|
1027
|
+
releaseGoal(claim.goal_id, rootDir);
|
|
1028
|
+
stopProgressWatcher(claim.goal_id);
|
|
1029
|
+
logGoalEvent({
|
|
1030
|
+
event: 'goal_driver_orphan_released',
|
|
1031
|
+
goal_id: claim.goal_id,
|
|
1032
|
+
session_id: claim.session_id,
|
|
1033
|
+
claim_id: claim.claim_id,
|
|
1034
|
+
session_status: s.status,
|
|
1035
|
+
reason: 'session_terminal_pid_dead',
|
|
1036
|
+
}, rootDir);
|
|
1037
|
+
out.released.push(claim.goal_id);
|
|
1038
|
+
}
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
992
1042
|
// ─ F-49 Đợt 2: Kanban board page ─
|
|
993
1043
|
//
|
|
994
1044
|
// Self-contained HTML at /board?t=<token>. Polls /voice/board for JSON, then
|
|
@@ -1047,6 +1097,25 @@ function boardHtml(token) {
|
|
|
1047
1097
|
.empty { color: var(--dim); font-style: italic; font-size: 12px; padding: 8px 0; }
|
|
1048
1098
|
.toast { position: fixed; bottom: 16px; right: 16px; background: var(--card); border: 1px solid var(--accent); padding: 10px 14px; border-radius: 6px; font-size: 13px; max-width: 360px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
|
|
1049
1099
|
.toast.err { border-color: var(--err); }
|
|
1100
|
+
.gtopline { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
|
|
1101
|
+
.gtopline .ghead { min-width: 0; }
|
|
1102
|
+
.goal-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
|
1103
|
+
.goal-start-btn { background: var(--accent); color: #0d1117; border: 0; border-radius: 4px; padding: 4px 10px; font-size: 11px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
|
1104
|
+
.goal-start-btn:hover { filter: brightness(1.15); }
|
|
1105
|
+
.goal-start-btn:disabled { cursor: not-allowed; opacity: 0.85; }
|
|
1106
|
+
.goal-start-btn.running { background: var(--warn); color: #0d1117; animation: gd-pulse 1.4s ease-in-out infinite; }
|
|
1107
|
+
.goal-stop-btn { background: var(--err); color: #fff; border: 0; border-radius: 4px; padding: 4px 10px; font-size: 11px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
|
1108
|
+
.goal-stop-btn:hover { filter: brightness(1.15); }
|
|
1109
|
+
@keyframes gd-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }
|
|
1110
|
+
.gd-cap { color: var(--dim); font-size: 10px; margin-top: 2px; }
|
|
1111
|
+
.gd-progress { margin-top: 6px; padding: 6px 8px; background: rgba(210, 153, 34, 0.08); border-left: 2px solid var(--warn); border-radius: 3px; }
|
|
1112
|
+
.gd-progress .current { color: var(--warn); font-size: 12px; font-weight: 600; }
|
|
1113
|
+
.gd-progress .current.bucket-pending { color: var(--dim); }
|
|
1114
|
+
.gd-progress .stats { color: var(--dim); font-size: 11px; margin-top: 2px; }
|
|
1115
|
+
.gd-progress .stats .alive { color: var(--ok); }
|
|
1116
|
+
.gd-progress .stats .stale { color: var(--err); }
|
|
1117
|
+
.gd-progress .pbar { height: 4px; background: var(--border); border-radius: 2px; margin-top: 4px; overflow: hidden; }
|
|
1118
|
+
.gd-progress .pbar .pfill { height: 100%; background: var(--warn); transition: width 0.4s; }
|
|
1050
1119
|
</style>
|
|
1051
1120
|
</head><body>
|
|
1052
1121
|
<div class="topbar">
|
|
@@ -1132,13 +1201,96 @@ function render(d) {
|
|
|
1132
1201
|
for (const g of d.goals) {
|
|
1133
1202
|
const gc = document.createElement('div');
|
|
1134
1203
|
gc.className = 'goal-card cycle-' + (g.cycle || '');
|
|
1204
|
+
gc.dataset.goalId = g.goal_id;
|
|
1135
1205
|
const gid = document.createElement('div'); gid.className = 'gid';
|
|
1136
1206
|
gid.textContent = (g.icon ? g.icon + ' ' : '') + g.goal_id;
|
|
1137
1207
|
const gt = document.createElement('div'); gt.className = 'gtitle';
|
|
1138
1208
|
gt.textContent = g.title;
|
|
1139
1209
|
const gm = document.createElement('div'); gm.className = 'gmeta';
|
|
1140
1210
|
gm.textContent = g.status + (g.target_date ? ' · ' + g.target_date : '') + (g.cycle ? ' · ' + g.cycle : '');
|
|
1141
|
-
|
|
1211
|
+
// ST-3: Start button per goal — disabled while claim is active.
|
|
1212
|
+
const topline = document.createElement('div'); topline.className = 'gtopline';
|
|
1213
|
+
const ghead = document.createElement('div'); ghead.className = 'ghead';
|
|
1214
|
+
ghead.appendChild(gid); ghead.appendChild(gt);
|
|
1215
|
+
topline.appendChild(ghead);
|
|
1216
|
+
const startBtn = document.createElement('button');
|
|
1217
|
+
startBtn.type = 'button';
|
|
1218
|
+
startBtn.className = 'goal-start-btn' + (g.claim ? ' running' : '');
|
|
1219
|
+
startBtn.disabled = !!g.claim;
|
|
1220
|
+
const lang = (navigator.language || 'en').toLowerCase().startsWith('vi') ? 'vi' : 'en';
|
|
1221
|
+
if (g.claim) {
|
|
1222
|
+
startBtn.textContent = lang === 'vi' ? 'Đang chạy…' : 'Running…';
|
|
1223
|
+
startBtn.title = lang === 'vi'
|
|
1224
|
+
? ('Session ' + (g.claim.session_id || '').slice(-8) + ' · cap ' + g.claim.max_minutes + ' phút')
|
|
1225
|
+
: ('Session ' + (g.claim.session_id || '').slice(-8) + ' · cap ' + g.claim.max_minutes + 'm');
|
|
1226
|
+
} else {
|
|
1227
|
+
startBtn.textContent = lang === 'vi' ? 'Bắt đầu ▶' : 'Start ▶';
|
|
1228
|
+
startBtn.title = lang === 'vi'
|
|
1229
|
+
? 'Spawn agent ngầm drive goal tới hoàn thành (cap 25 phút)'
|
|
1230
|
+
: 'Spawn background driver to complete this goal (25-min cap)';
|
|
1231
|
+
}
|
|
1232
|
+
startBtn.onclick = (ev) => { ev.stopPropagation(); triggerGoalStart(g, lang); };
|
|
1233
|
+
const actions = document.createElement('div'); actions.className = 'goal-actions';
|
|
1234
|
+
actions.appendChild(startBtn);
|
|
1235
|
+
if (g.claim) {
|
|
1236
|
+
// Stop button only shown while a claim is active. Clicking it sends
|
|
1237
|
+
// SIGTERM (tree-kill server-side), releases the claim, and re-renders.
|
|
1238
|
+
const stopBtn = document.createElement('button');
|
|
1239
|
+
stopBtn.type = 'button';
|
|
1240
|
+
stopBtn.className = 'goal-stop-btn';
|
|
1241
|
+
stopBtn.textContent = lang === 'vi' ? 'Dừng ◼' : 'Stop ◼';
|
|
1242
|
+
stopBtn.title = lang === 'vi'
|
|
1243
|
+
? 'Gửi SIGTERM cho session + release claim'
|
|
1244
|
+
: 'SIGTERM the session + release claim';
|
|
1245
|
+
stopBtn.onclick = (ev) => { ev.stopPropagation(); triggerGoalStop(g, lang); };
|
|
1246
|
+
actions.appendChild(stopBtn);
|
|
1247
|
+
}
|
|
1248
|
+
topline.appendChild(actions);
|
|
1249
|
+
gc.appendChild(topline);
|
|
1250
|
+
gc.appendChild(gm);
|
|
1251
|
+
if (g.claim) {
|
|
1252
|
+
const cap = document.createElement('div'); cap.className = 'gd-cap';
|
|
1253
|
+
cap.textContent = (lang === 'vi' ? 'driver hết hạn lúc ' : 'driver expires ') + g.claim.expires_at;
|
|
1254
|
+
gc.appendChild(cap);
|
|
1255
|
+
// Live progress chunk: current subtask + X/Y + last-activity timer.
|
|
1256
|
+
if (g.claim.progress) {
|
|
1257
|
+
const pr = g.claim.progress;
|
|
1258
|
+
const wrap = document.createElement('div'); wrap.className = 'gd-progress';
|
|
1259
|
+
const cur = document.createElement('div');
|
|
1260
|
+
cur.className = 'current bucket-' + (pr.current ? pr.current.status_bucket : 'idle');
|
|
1261
|
+
if (pr.current) {
|
|
1262
|
+
const icon = pr.current.status_bucket === 'in_progress' ? '▶ ' : '⬜ ';
|
|
1263
|
+
cur.textContent = icon + pr.current.st_id + ' · ' + pr.current.title;
|
|
1264
|
+
} else {
|
|
1265
|
+
cur.textContent = (lang === 'vi' ? 'Không có subtask đang chạy' : 'No subtask currently in flight');
|
|
1266
|
+
}
|
|
1267
|
+
const stats = document.createElement('div'); stats.className = 'stats';
|
|
1268
|
+
const doneStr = pr.done + '/' + pr.total + (lang === 'vi' ? ' xong' : ' done')
|
|
1269
|
+
+ ' · ' + pr.percent + '%';
|
|
1270
|
+
stats.textContent = doneStr;
|
|
1271
|
+
const sep = document.createElement('span'); sep.textContent = ' · ';
|
|
1272
|
+
stats.appendChild(sep);
|
|
1273
|
+
const ago = document.createElement('span');
|
|
1274
|
+
const aliveInfo = formatActivity(pr.last_activity_at || g.claim.claimed_at, lang);
|
|
1275
|
+
ago.className = aliveInfo.stale ? 'stale' : 'alive';
|
|
1276
|
+
ago.textContent = aliveInfo.text;
|
|
1277
|
+
stats.appendChild(ago);
|
|
1278
|
+
if (pr.blocked > 0) {
|
|
1279
|
+
const blk = document.createElement('span');
|
|
1280
|
+
blk.style.color = 'var(--err)';
|
|
1281
|
+
blk.textContent = ' · ' + pr.blocked + (lang === 'vi' ? ' blocked' : ' blocked');
|
|
1282
|
+
stats.appendChild(blk);
|
|
1283
|
+
}
|
|
1284
|
+
const bar = document.createElement('div'); bar.className = 'pbar';
|
|
1285
|
+
const fill = document.createElement('div'); fill.className = 'pfill';
|
|
1286
|
+
fill.style.width = pr.percent + '%';
|
|
1287
|
+
bar.appendChild(fill);
|
|
1288
|
+
wrap.appendChild(cur);
|
|
1289
|
+
wrap.appendChild(stats);
|
|
1290
|
+
wrap.appendChild(bar);
|
|
1291
|
+
gc.appendChild(wrap);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1142
1294
|
if (g.progress && Number.isFinite(g.progress.percent)) {
|
|
1143
1295
|
const bar = document.createElement('div'); bar.className = 'progress-bar';
|
|
1144
1296
|
const fill = document.createElement('div'); fill.className = 'fill';
|
|
@@ -1212,6 +1364,71 @@ function bucketForStatus(s) {
|
|
|
1212
1364
|
return 'terminal';
|
|
1213
1365
|
}
|
|
1214
1366
|
|
|
1367
|
+
// Returns { text, stale } — relative-time label + a flag the UI can use to
|
|
1368
|
+
// flip "alive" green to "stale" red after 90s of silence.
|
|
1369
|
+
function formatActivity(iso, lang) {
|
|
1370
|
+
const L = lang === 'vi' ? 'vi' : 'en';
|
|
1371
|
+
if (!iso) return { text: L === 'vi' ? 'chưa có hoạt động' : 'no activity yet', stale: true };
|
|
1372
|
+
const ms = Date.now() - Date.parse(iso);
|
|
1373
|
+
if (!Number.isFinite(ms) || ms < 0) return { text: iso, stale: false };
|
|
1374
|
+
const sec = Math.floor(ms / 1000);
|
|
1375
|
+
const STALE_AT_MS = 90 * 1000;
|
|
1376
|
+
const stale = ms > STALE_AT_MS;
|
|
1377
|
+
let unit;
|
|
1378
|
+
if (sec < 60) unit = sec + 's';
|
|
1379
|
+
else if (sec < 3600) unit = Math.floor(sec / 60) + 'm';
|
|
1380
|
+
else if (sec < 86400) unit = Math.floor(sec / 3600) + 'h';
|
|
1381
|
+
else unit = Math.floor(sec / 86400) + 'd';
|
|
1382
|
+
return {
|
|
1383
|
+
text: L === 'vi' ? ('hoạt động ' + unit + ' trước') : (unit + ' ago'),
|
|
1384
|
+
stale,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function triggerGoalStop(goal, lang) {
|
|
1389
|
+
const L = lang === 'vi' ? 'vi' : 'en';
|
|
1390
|
+
if (!confirm(L === 'vi'
|
|
1391
|
+
? ('Dừng driver cho ' + goal.goal_id + '? Session sẽ bị SIGTERM.')
|
|
1392
|
+
: ('Stop driver for ' + goal.goal_id + '? The session will be SIGTERMed.'))) return;
|
|
1393
|
+
showToast((L === 'vi' ? 'Đang dừng: ' : 'Stopping: ') + goal.goal_id + '…');
|
|
1394
|
+
fetch('/voice/goal-stop', {
|
|
1395
|
+
method: 'POST',
|
|
1396
|
+
headers: { 'Content-Type': 'application/json', 'X-Voice-Token': TOKEN },
|
|
1397
|
+
body: JSON.stringify({ goal_id: goal.goal_id }),
|
|
1398
|
+
}).then((r) => r.json().then((j) => ({ status: r.status, j }))).then(({ status, j }) => {
|
|
1399
|
+
if (j.ok) {
|
|
1400
|
+
showToast((L === 'vi' ? 'Đã dừng · ' : 'Stopped · ') + (j.kill_method || ''));
|
|
1401
|
+
setTimeout(loadBoard, 400);
|
|
1402
|
+
} else if (status === 404) {
|
|
1403
|
+
showToast(L === 'vi' ? 'Không có driver đang chạy' : 'No driver running', true);
|
|
1404
|
+
setTimeout(loadBoard, 200);
|
|
1405
|
+
} else {
|
|
1406
|
+
showToast((L === 'vi' ? 'Lỗi: ' : 'Failed: ') + (j.error || 'unknown'), true);
|
|
1407
|
+
}
|
|
1408
|
+
}).catch((e) => showToast((L === 'vi' ? 'Lỗi mạng: ' : 'Network error: ') + e.message, true));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function triggerGoalStart(goal, lang) {
|
|
1412
|
+
const L = lang === 'vi' ? 'vi' : 'en';
|
|
1413
|
+
showToast((L === 'vi' ? 'Đang khởi động driver: ' : 'Driving: ') + goal.goal_id + '…');
|
|
1414
|
+
fetch('/voice/goal-start', {
|
|
1415
|
+
method: 'POST',
|
|
1416
|
+
headers: { 'Content-Type': 'application/json', 'X-Voice-Token': TOKEN },
|
|
1417
|
+
body: JSON.stringify({ goal_id: goal.goal_id, lang: L }),
|
|
1418
|
+
}).then((r) => r.json().then((j) => ({ status: r.status, j }))).then(({ status, j }) => {
|
|
1419
|
+
if (j.ok) {
|
|
1420
|
+
const tail = (j.session_id || '').slice(-8);
|
|
1421
|
+
showToast((L === 'vi' ? 'Driver chạy · ' : 'Driver running · ')
|
|
1422
|
+
+ tail + ' · cap ' + j.budget.max_minutes + (L === 'vi' ? ' phút' : 'm'));
|
|
1423
|
+
setTimeout(loadBoard, 400);
|
|
1424
|
+
} else if (status === 409) {
|
|
1425
|
+
showToast(L === 'vi' ? 'Đã có driver chạy — xem cột Sessions' : 'Already driving — see Sessions column', true);
|
|
1426
|
+
} else {
|
|
1427
|
+
showToast((L === 'vi' ? 'Lỗi: ' : 'Failed: ') + (j.error || 'unknown'), true);
|
|
1428
|
+
}
|
|
1429
|
+
}).catch((e) => showToast((L === 'vi' ? 'Lỗi mạng: ' : 'Network error: ') + e.message, true));
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1215
1432
|
function triggerSpawnForTask(task, goal) {
|
|
1216
1433
|
const text = 'start agent for task ' + task.task_id + ' under goal ' + goal.goal_id;
|
|
1217
1434
|
showToast('Asking orchestrator: ' + text);
|
|
@@ -1266,7 +1483,7 @@ es.addEventListener('message', (ev) => {
|
|
|
1266
1483
|
let p; try { p = JSON.parse(ev.data); } catch { return; }
|
|
1267
1484
|
if (!p || !p.event) return;
|
|
1268
1485
|
const n = p.event;
|
|
1269
|
-
if (n.startsWith('session.') || n.startsWith('goal.') || n === 'debate_completed' || n === 'debate_started') {
|
|
1486
|
+
if (n.startsWith('session.') || n.startsWith('goal.') || n.startsWith('goal_driver_') || n === 'debate_completed' || n === 'debate_started') {
|
|
1270
1487
|
scheduleRefresh();
|
|
1271
1488
|
}
|
|
1272
1489
|
});
|
|
@@ -1686,6 +1903,151 @@ export async function voiceCommand(opts = {}) {
|
|
|
1686
1903
|
res.end(JSON.stringify({ ok: true, data: { roster, timeout_ms } }));
|
|
1687
1904
|
return;
|
|
1688
1905
|
}
|
|
1906
|
+
// goal-driver-mvp ST-2: POST /voice/goal-start spawns an autonomous
|
|
1907
|
+
// claude driver session for the supplied goal_id, gated by a per-goal
|
|
1908
|
+
// claim file (.dw/cache/goal-claims/<id>.json). The spawned agent runs
|
|
1909
|
+
// the F-43 workflow in a budgeted loop; we SIGTERM at the wall-clock
|
|
1910
|
+
// cap and release the claim. See task.md + ADR-0014 (whitelist).
|
|
1911
|
+
if (req.method === 'POST' && req.url === '/voice/goal-start') {
|
|
1912
|
+
if (req.headers['x-voice-token'] !== token) {
|
|
1913
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1914
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
let body = '';
|
|
1918
|
+
req.setEncoding('utf8');
|
|
1919
|
+
req.on('data', (c) => { body += c; if (body.length > 8192) req.destroy(); });
|
|
1920
|
+
req.on('end', async () => {
|
|
1921
|
+
let parsed;
|
|
1922
|
+
try { parsed = JSON.parse(body); }
|
|
1923
|
+
catch { res.writeHead(400); res.end(JSON.stringify({ ok: false, error: 'bad json' })); return; }
|
|
1924
|
+
const goalId = (parsed.goal_id || '').trim();
|
|
1925
|
+
if (!/^G-[A-Za-z0-9_-]{1,64}$/.test(goalId)) {
|
|
1926
|
+
res.writeHead(400); res.end(JSON.stringify({ ok: false, error: 'invalid goal_id' })); return;
|
|
1927
|
+
}
|
|
1928
|
+
const goalFile = join(rootDir, '.dw', 'goals', goalId, 'goal.md');
|
|
1929
|
+
if (!existsSync(goalFile)) {
|
|
1930
|
+
res.writeHead(404); res.end(JSON.stringify({ ok: false, error: `goal not found: ${goalId}` })); return;
|
|
1931
|
+
}
|
|
1932
|
+
const lang = (parsed.lang || 'en').toString().startsWith('vi') ? 'vi' : 'en';
|
|
1933
|
+
const budgetOpts = {
|
|
1934
|
+
max_iterations: parsed.max_iterations,
|
|
1935
|
+
max_minutes: parsed.max_minutes,
|
|
1936
|
+
lang,
|
|
1937
|
+
};
|
|
1938
|
+
// ST-8: sweep stale claims first so re-Start on a goal whose
|
|
1939
|
+
// previous driver exited cleanly works without manual cleanup.
|
|
1940
|
+
reconcileGoalDrivers(rootDir);
|
|
1941
|
+
const claimResult = claimGoal(goalId, null, budgetOpts, rootDir);
|
|
1942
|
+
if (!claimResult.ok) {
|
|
1943
|
+
const code = claimResult.reason === 'already_claimed' ? 409 : 400;
|
|
1944
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
1945
|
+
res.end(JSON.stringify({ ok: false, error: claimResult.reason, existing: claimResult.existing || null }));
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
const claim = claimResult.claim;
|
|
1949
|
+
const prompt = buildDriverPrompt(goalId, budgetOpts, rootDir);
|
|
1950
|
+
let spawnResult;
|
|
1951
|
+
try {
|
|
1952
|
+
spawnResult = await executeAction({
|
|
1953
|
+
name: 'start_session',
|
|
1954
|
+
args: { agent: 'claude', goal: prompt },
|
|
1955
|
+
rootDir,
|
|
1956
|
+
lang: lang === 'vi' ? 'vi-VN' : 'en-US',
|
|
1957
|
+
});
|
|
1958
|
+
} catch (e) {
|
|
1959
|
+
releaseGoal(goalId, rootDir);
|
|
1960
|
+
res.writeHead(500); res.end(JSON.stringify({ ok: false, error: `spawn failed: ${e.message}` }));
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
if (!spawnResult.ok) {
|
|
1964
|
+
releaseGoal(goalId, rootDir);
|
|
1965
|
+
res.writeHead(500); res.end(JSON.stringify({ ok: false, error: spawnResult.display || 'spawn failed' }));
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const sessionId = spawnResult.data && spawnResult.data.id;
|
|
1969
|
+
if (sessionId) attachSessionId(goalId, sessionId, rootDir);
|
|
1970
|
+
logGoalEvent({
|
|
1971
|
+
event: 'goal_driver_started',
|
|
1972
|
+
goal_id: goalId,
|
|
1973
|
+
session_id: sessionId || null,
|
|
1974
|
+
claim_id: claim.claim_id,
|
|
1975
|
+
max_iterations: claim.max_iterations,
|
|
1976
|
+
max_minutes: claim.max_minutes,
|
|
1977
|
+
lang,
|
|
1978
|
+
}, rootDir);
|
|
1979
|
+
// Surface live progress: watch the linked task.md and emit a
|
|
1980
|
+
// goal_driver_subtask_progress event whenever Section 3 status
|
|
1981
|
+
// icons change. The events flow through the existing SSE broker
|
|
1982
|
+
// (events-global.jsonl watcher) to the board UI.
|
|
1983
|
+
const watchResult = startProgressWatcher(goalId, rootDir, ({ changes, snapshot }) => {
|
|
1984
|
+
const done = snapshot.filter((s) => s.status_bucket === 'done').length;
|
|
1985
|
+
const total = snapshot.length;
|
|
1986
|
+
const current = snapshot.find((s) => s.status_bucket === 'in_progress')
|
|
1987
|
+
|| snapshot.find((s) => s.status_bucket === 'pending');
|
|
1988
|
+
logGoalEvent({
|
|
1989
|
+
event: 'goal_driver_subtask_progress',
|
|
1990
|
+
goal_id: goalId,
|
|
1991
|
+
session_id: sessionId || null,
|
|
1992
|
+
claim_id: claim.claim_id,
|
|
1993
|
+
changes,
|
|
1994
|
+
done,
|
|
1995
|
+
total,
|
|
1996
|
+
current: current ? { st_id: current.st_id, title: current.title.slice(0, 120) } : null,
|
|
1997
|
+
}, rootDir);
|
|
1998
|
+
});
|
|
1999
|
+
if (!watchResult.ok && watchResult.reason !== 'no_linked_task') {
|
|
2000
|
+
logGoalEvent({
|
|
2001
|
+
event: 'goal_driver_watch_failed',
|
|
2002
|
+
goal_id: goalId,
|
|
2003
|
+
claim_id: claim.claim_id,
|
|
2004
|
+
reason: watchResult.reason,
|
|
2005
|
+
}, rootDir);
|
|
2006
|
+
}
|
|
2007
|
+
// Wall-clock kill switch — the cap inside the prompt is advisory;
|
|
2008
|
+
// this setTimeout is the enforcer. Released claim + SIGTERM on
|
|
2009
|
+
// fire; unref so an idle server doesn't keep the process alive
|
|
2010
|
+
// just for the timer.
|
|
2011
|
+
const killTimer = setTimeout(() => {
|
|
2012
|
+
try {
|
|
2013
|
+
const s = sessionId ? getSession(sessionId, rootDir) : null;
|
|
2014
|
+
if (s && s.pid && isAlive(s.pid)) {
|
|
2015
|
+
// F-50 fix: tree-kill so the .cmd wrapper's claude.exe
|
|
2016
|
+
// grandchild on Win32 doesn't survive as an orphan.
|
|
2017
|
+
const killResult = killProcessTree(s.pid, 'SIGTERM');
|
|
2018
|
+
updateSessionStatus(sessionId, { status: 'stopped' }, rootDir);
|
|
2019
|
+
appendEvent(sessionId, {
|
|
2020
|
+
event: 'stopped', signal: 'SIGTERM',
|
|
2021
|
+
by: 'goal-driver-budget', kill_method: killResult.method,
|
|
2022
|
+
}, rootDir);
|
|
2023
|
+
}
|
|
2024
|
+
logGoalEvent({
|
|
2025
|
+
event: 'goal_driver_terminated',
|
|
2026
|
+
goal_id: goalId,
|
|
2027
|
+
session_id: sessionId || null,
|
|
2028
|
+
claim_id: claim.claim_id,
|
|
2029
|
+
reason: 'budget_exhausted',
|
|
2030
|
+
}, rootDir);
|
|
2031
|
+
} catch { /* best-effort */ }
|
|
2032
|
+
stopProgressWatcher(goalId);
|
|
2033
|
+
releaseGoal(goalId, rootDir);
|
|
2034
|
+
}, claim.max_minutes * 60_000);
|
|
2035
|
+
if (typeof killTimer.unref === 'function') killTimer.unref();
|
|
2036
|
+
|
|
2037
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2038
|
+
res.end(JSON.stringify({
|
|
2039
|
+
ok: true,
|
|
2040
|
+
session_id: sessionId,
|
|
2041
|
+
claim_id: claim.claim_id,
|
|
2042
|
+
goal_id: goalId,
|
|
2043
|
+
budget: { max_iterations: claim.max_iterations, max_minutes: claim.max_minutes },
|
|
2044
|
+
expires_at: claim.expires_at,
|
|
2045
|
+
lang,
|
|
2046
|
+
}));
|
|
2047
|
+
});
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1689
2051
|
// F-49 Đợt 2: Kanban board JSON + HTML page.
|
|
1690
2052
|
if (req.method === 'GET' && req.url.startsWith('/voice/board')) {
|
|
1691
2053
|
const urlObj = new URL(req.url, `http://localhost:${port}`);
|
|
@@ -1695,6 +2057,10 @@ export async function voiceCommand(opts = {}) {
|
|
|
1695
2057
|
return;
|
|
1696
2058
|
}
|
|
1697
2059
|
try {
|
|
2060
|
+
// ST-8: lazy reconcile drops stale claims whose session has
|
|
2061
|
+
// exited cleanly. Without this, "Running…" sticks until the
|
|
2062
|
+
// wall-clock TTL fires (up to max_minutes after natural exit).
|
|
2063
|
+
reconcileGoalDrivers(rootDir);
|
|
1698
2064
|
const data = buildBoardData(rootDir);
|
|
1699
2065
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1700
2066
|
res.end(JSON.stringify({ ok: true, data }, null, 2));
|
|
@@ -1704,6 +2070,69 @@ export async function voiceCommand(opts = {}) {
|
|
|
1704
2070
|
}
|
|
1705
2071
|
return;
|
|
1706
2072
|
}
|
|
2073
|
+
// goal-driver-mvp ST-8: POST /voice/goal-stop kills the driver
|
|
2074
|
+
// session (tree-kill so the Win32 cmd.exe→claude.exe grandchild
|
|
2075
|
+
// dies too), releases the claim, stops the watcher, and logs a
|
|
2076
|
+
// goal_driver_stopped event for forensics.
|
|
2077
|
+
if (req.method === 'POST' && req.url === '/voice/goal-stop') {
|
|
2078
|
+
if (req.headers['x-voice-token'] !== token) {
|
|
2079
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
2080
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
let body = '';
|
|
2084
|
+
req.setEncoding('utf8');
|
|
2085
|
+
req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
|
|
2086
|
+
req.on('end', () => {
|
|
2087
|
+
let parsed;
|
|
2088
|
+
try { parsed = JSON.parse(body); }
|
|
2089
|
+
catch { res.writeHead(400); res.end(JSON.stringify({ ok: false, error: 'bad json' })); return; }
|
|
2090
|
+
const goalId = (parsed.goal_id || '').trim();
|
|
2091
|
+
if (!/^G-[A-Za-z0-9_-]{1,64}$/.test(goalId)) {
|
|
2092
|
+
res.writeHead(400); res.end(JSON.stringify({ ok: false, error: 'invalid goal_id' })); return;
|
|
2093
|
+
}
|
|
2094
|
+
const claim = readClaim(goalId, rootDir);
|
|
2095
|
+
if (!claim) {
|
|
2096
|
+
res.writeHead(404); res.end(JSON.stringify({ ok: false, error: 'not_driving' })); return;
|
|
2097
|
+
}
|
|
2098
|
+
let killResult = { ok: true, method: 'no_session' };
|
|
2099
|
+
let sessionStatus = 'unknown';
|
|
2100
|
+
if (claim.session_id) {
|
|
2101
|
+
const s = getSession(claim.session_id, rootDir);
|
|
2102
|
+
if (s) {
|
|
2103
|
+
sessionStatus = s.status;
|
|
2104
|
+
if (s.pid && isAlive(s.pid)) {
|
|
2105
|
+
killResult = killProcessTree(s.pid, 'SIGTERM');
|
|
2106
|
+
updateSessionStatus(claim.session_id, { status: 'stopped' }, rootDir);
|
|
2107
|
+
appendEvent(claim.session_id, {
|
|
2108
|
+
event: 'stopped', signal: 'SIGTERM',
|
|
2109
|
+
by: 'goal-driver-stop-button', kill_method: killResult.method,
|
|
2110
|
+
}, rootDir);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
stopProgressWatcher(goalId);
|
|
2115
|
+
releaseGoal(goalId, rootDir);
|
|
2116
|
+
logGoalEvent({
|
|
2117
|
+
event: 'goal_driver_stopped',
|
|
2118
|
+
goal_id: goalId,
|
|
2119
|
+
session_id: claim.session_id || null,
|
|
2120
|
+
claim_id: claim.claim_id,
|
|
2121
|
+
kill_method: killResult.method,
|
|
2122
|
+
kill_ok: killResult.ok,
|
|
2123
|
+
prior_session_status: sessionStatus,
|
|
2124
|
+
by: 'stop_button',
|
|
2125
|
+
}, rootDir);
|
|
2126
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2127
|
+
res.end(JSON.stringify({
|
|
2128
|
+
ok: true,
|
|
2129
|
+
goal_id: goalId,
|
|
2130
|
+
kill_method: killResult.method,
|
|
2131
|
+
session_id: claim.session_id || null,
|
|
2132
|
+
}));
|
|
2133
|
+
});
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
1707
2136
|
if (req.method === 'GET' && (req.url === '/board' || req.url.startsWith('/board?'))) {
|
|
1708
2137
|
// Token authorization happens in JSON fetch — the HTML itself is
|
|
1709
2138
|
// public scaffolding (matches the / behavior).
|
package/src/lib/board-data.mjs
CHANGED
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
27
27
|
import { join, basename } from 'node:path';
|
|
28
|
-
import yaml from 'js-yaml';
|
|
29
28
|
import { listSessions } from './session-store.mjs';
|
|
29
|
+
import { readClaim } from './goal-driver.mjs';
|
|
30
|
+
import { computeGoalProgress } from './goal-progress.mjs';
|
|
31
|
+
import { parseSubtasks, readFrontmatter, listTaskDirs } from './task-md-utils.mjs';
|
|
30
32
|
|
|
31
33
|
const GOALS_INDEX_PATH = '.dw/goals/goals-index.json';
|
|
32
34
|
const GOALS_DIR = '.dw/goals';
|
|
@@ -42,62 +44,9 @@ function safeReadGoalsIndex(rootDir) {
|
|
|
42
44
|
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { goals: {} }; }
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
if (!existsSync(file)) return {};
|
|
49
|
-
let txt;
|
|
50
|
-
try { txt = readFileSync(file, 'utf8'); } catch { return {}; }
|
|
51
|
-
const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
52
|
-
if (!m) return {};
|
|
53
|
-
try { return yaml.load(m[1]) || {}; } catch { return {}; }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Section 3 Subtask Tracker parser. Matches the `| ST-N | title | status | date | notes |`
|
|
57
|
-
// row pattern used across the repo. Returns an array of subtask rows.
|
|
58
|
-
const STATUS_ICONS = {
|
|
59
|
-
'⬜': 'pending',
|
|
60
|
-
'🟡': 'in_progress',
|
|
61
|
-
'✅': 'done',
|
|
62
|
-
'🔴': 'blocked',
|
|
63
|
-
'⏸': 'paused',
|
|
64
|
-
};
|
|
65
|
-
function parseSubtasks(taskPath) {
|
|
66
|
-
if (!existsSync(taskPath)) return [];
|
|
67
|
-
let txt;
|
|
68
|
-
try { txt = readFileSync(taskPath, 'utf8'); } catch { return []; }
|
|
69
|
-
const sec = txt.match(/^## 3\.[^\n]*\n([\s\S]*?)(?=^## 4\.|$(?![\s\S]))/m);
|
|
70
|
-
if (!sec) return [];
|
|
71
|
-
const out = [];
|
|
72
|
-
for (const line of sec[1].split('\n')) {
|
|
73
|
-
// | ST-1 | "Subtask title" | ✅ Done | 2026-05-25 | notes |
|
|
74
|
-
const m = line.match(/^\|\s*(ST-[\w.-]+)\s*\|\s*(.+?)\s*\|\s*([⬜🟡✅🔴⏸])\s*([A-Za-z ]+)?\s*\|\s*([^|]*)\|\s*([^|]*)\|/);
|
|
75
|
-
if (!m) continue;
|
|
76
|
-
const icon = m[3];
|
|
77
|
-
const statusBucket = STATUS_ICONS[icon] || 'unknown';
|
|
78
|
-
const statusLabel = (m[4] || '').trim();
|
|
79
|
-
out.push({
|
|
80
|
-
st_id: m[1].trim(),
|
|
81
|
-
title: m[2].replace(/`/g, '').trim(),
|
|
82
|
-
status_bucket: statusBucket,
|
|
83
|
-
status_icon: icon,
|
|
84
|
-
status_label: statusLabel,
|
|
85
|
-
date: m[5].trim(),
|
|
86
|
-
notes: m[6].trim().slice(0, 160),
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function listTaskDirs(rootDir) {
|
|
93
|
-
const dir = join(rootDir, TASKS_DIR);
|
|
94
|
-
if (!existsSync(dir)) return [];
|
|
95
|
-
return readdirSync(dir)
|
|
96
|
-
.filter((entry) => {
|
|
97
|
-
if (entry.startsWith('.') || entry === 'archive') return false;
|
|
98
|
-
try { return statSync(join(dir, entry)).isDirectory(); } catch { return false; }
|
|
99
|
-
});
|
|
100
|
-
}
|
|
47
|
+
// Parsers (parseSubtasks / readFrontmatter / listTaskDirs) live in
|
|
48
|
+
// task-md-utils.mjs so goal-progress.mjs can share them without forming a
|
|
49
|
+
// circular import via board-data.mjs.
|
|
101
50
|
|
|
102
51
|
// ─ Public API ──────────────────────────────────────────────────────────────
|
|
103
52
|
|
|
@@ -145,6 +94,10 @@ export function buildBoardData(rootDir, opts = {}) {
|
|
|
145
94
|
for (const [goal_id, g] of goalsIndexEntries) {
|
|
146
95
|
if (g.archived_at) continue; // hide archived
|
|
147
96
|
const tasks = (byGoal.get(goal_id) || []).slice(0, MAX_TASKS_PER_GOAL);
|
|
97
|
+
// goal-driver-mvp ST-6: attach live claim state (or null) so the board
|
|
98
|
+
// UI can render "Running…" disabled state on the Start button without
|
|
99
|
+
// a second roundtrip. readClaim auto-expires stale entries.
|
|
100
|
+
const claim = readClaim(goal_id, rootDir);
|
|
148
101
|
goals.push({
|
|
149
102
|
goal_id,
|
|
150
103
|
title: g.title || goal_id,
|
|
@@ -157,6 +110,18 @@ export function buildBoardData(rootDir, opts = {}) {
|
|
|
157
110
|
last_updated: g.last_updated || null,
|
|
158
111
|
linked_task_ids: g.linked_task_ids || [],
|
|
159
112
|
tasks,
|
|
113
|
+
claim: claim ? {
|
|
114
|
+
claim_id: claim.claim_id,
|
|
115
|
+
session_id: claim.session_id || null,
|
|
116
|
+
claimed_at: claim.claimed_at,
|
|
117
|
+
expires_at: claim.expires_at,
|
|
118
|
+
max_iterations: claim.max_iterations,
|
|
119
|
+
max_minutes: claim.max_minutes,
|
|
120
|
+
// ST-7 (goal-driver-mvp v2): live progress derived from the linked
|
|
121
|
+
// task.md Section 3 + last goal_driver_* event timestamp. null when
|
|
122
|
+
// no linked task is wired up.
|
|
123
|
+
progress: claim ? computeGoalProgress(goal_id, rootDir) : null,
|
|
124
|
+
} : null,
|
|
160
125
|
});
|
|
161
126
|
}
|
|
162
127
|
|
|
@@ -197,6 +162,7 @@ export function buildBoardData(rootDir, opts = {}) {
|
|
|
197
162
|
subtasks_blocked: 0,
|
|
198
163
|
sessions_running: sessions.filter((s) => s.status === 'running').length,
|
|
199
164
|
sessions_total: sessions.length,
|
|
165
|
+
goals_driving: goals.filter((g) => g.claim).length,
|
|
200
166
|
};
|
|
201
167
|
for (const t of allTasks) {
|
|
202
168
|
for (const st of t.subtasks) {
|