@kylecheng3146/agent-ops 0.8.0 → 0.8.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/dist/packages/cli/src/args.js +56 -1
- package/dist/packages/cli/src/bin.js +29 -2
- package/dist/packages/cli/src/cli.js +9 -3
- package/dist/packages/cli/src/codex-loop-process.js +6 -0
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/review.js +9 -3
- package/dist/packages/cli/src/commands/task.js +44 -2
- package/dist/packages/cli/src/debug-intake.js +245 -0
- package/dist/packages/cli/src/hook-process.js +110 -37
- package/dist/runtime/src/adapters/agy/config.js +3 -2
- package/dist/runtime/src/adapters/claude/config.js +4 -3
- package/dist/runtime/src/adapters/codex/config.js +7 -3
- package/dist/runtime/src/adapters/codex/events.js +2 -1
- package/dist/runtime/src/adapters/codex/input.js +20 -2
- package/dist/runtime/src/adapters/codex/output.js +4 -1
- package/dist/runtime/src/config/merge.js +1 -1
- package/dist/runtime/src/hooks/completion-gate.js +15 -0
- package/dist/runtime/src/install/harness.js +2 -0
- package/dist/runtime/src/install/plan.js +3 -3
- package/dist/runtime/src/install/profiles.js +2 -0
- package/dist/runtime/src/office/art.js +2 -1
- package/dist/runtime/src/office/layout.js +6 -3
- package/dist/runtime/src/office/page.js +133 -9
- package/dist/runtime/src/office/scene.js +13 -4
- package/dist/runtime/src/office/snapshot.js +12 -0
- package/dist/runtime/src/parallel/manage.js +19 -2
- package/dist/runtime/src/review/attestation.js +4 -1
- package/dist/runtime/src/review/execute.js +1 -1
- package/dist/runtime/src/review/slots.js +59 -44
- package/dist/runtime/src/run/hosts/codex.js +1 -1
- package/dist/runtime/src/run/transport.js +3 -0
- package/dist/runtime/src/schema/validate.js +8 -2
- package/dist/runtime/src/security/permissions.js +3 -1
- package/dist/runtime/src/task/completion.js +20 -1
- package/dist/runtime/src/task/contract.js +1 -1
- package/dist/runtime/src/task/debug-intake.js +12 -0
- package/dist/runtime/src/task/debug-state.js +130 -0
- package/dist/runtime/src/task/debug-user-event.js +93 -0
- package/dist/runtime/src/task/debug.js +586 -0
- package/dist/runtime/src/task/service.js +46 -0
- package/dist/runtime/src/task/store.js +8 -1
- package/dist/runtime/src/verify/acceptance-replay.js +9 -4
- package/dist/runtime/src/verify/service.js +21 -2
- package/docs/en/guides/task-debug.md +111 -0
- package/docs/en/spec/harness-adapters.md +15 -2
- package/docs/zh-TW/guides/task-debug.md +66 -0
- package/docs/zh-TW/spec/harness-adapters.md +11 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +30 -2
- package/schemas/manifest.schema.json +1 -0
- package/schemas/task.schema.json +62 -2
|
@@ -159,12 +159,12 @@ var languageButton = document.getElementById("language"), live = document.getEle
|
|
|
159
159
|
var snapshot = null, model = null, frame = 0, offline = false, scale = 1, scaleY = 1;
|
|
160
160
|
var mode = "overview", selectedKey = null, showRecent = false, detailPage = 0, dialogClose = null, detailPreviousFocus = null, detailTarget = null;
|
|
161
161
|
var detailSignature = "";
|
|
162
|
-
var people = {}, staffMemory = {}, viewer = spawnViewer(), doorFrames = {}, images = {}, bases = {}, roomCanvases = {}, overviewSignature = "", hudSignature = "", labelNodes = {}, lastFigures = [], stageCanvas = null;
|
|
162
|
+
var people = {}, staffMemory = {}, glances = {}, viewer = spawnViewer(), doorFrames = {}, images = {}, bases = {}, roomCanvases = {}, overviewSignature = "", hudSignature = "", labelNodes = {}, lastFigures = [], stageCanvas = null;
|
|
163
163
|
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
164
164
|
function spawnViewer(){ return {x: Math.round(LAYOUT.spawn.x / 2) * 2, y: Math.round(LAYOUT.spawn.y / 2) * 2, path: [], dir: "up", step: 0}; }
|
|
165
165
|
var STRINGS = {
|
|
166
|
-
en: {copyFailed:"Copy manually", copyHint:"Clipboard unavailable; select the command and copy it manually.", checks:"Acceptance checks", unfinished:"Not complete", sessionEnded:"Session ended", attention:"Needs attention", verifyFailed:"Verification failed", reviewFailed:"Review failed", blockedUnknown:"Blocked; reason not provided", openQuestion:"View questions and commands", detailsAction:"Details and commands", stale:"Reconnecting; showing last known state", viewer:"You", overview:"Office overview", rooms:"rooms", people:"people", recent:"Recently completed", recentOn:"Hide completed", repos:"Repository", allRepos:"All repositories", noTask:"No task", noTaskHint:"Nothing to verify or review", panel_task:"Whiteboard", panel_diff:"Screen", panel_verify:"QA board", panel_review:"Review desk", panel_integration:"Sorting table", noChanges:"No changed files yet.", filesCount:"files", notReviewed:"Not reviewed yet.", firstRound:"Round 1", recheck:"Re-check", blocking:"blocking", refutedYes:"The re-check overturned round 1.", refutedNo:"The re-check upheld round 1.", worktree:"Worktree", branch:"Branch", base:"Base", ahead:"Commits ahead", commitStep:"Commit", finishStep:"worktree finish (merge)", hideList:"Hide list »", showList:"« Work list", moveHint:"Arrow keys or WASD walk; Enter talks to the nearest person; Esc returns.", critPass:"PASS", critFail:"FAIL", critUnknown:"Undetermined", critPending:"Not verified yet", noCriteria:"This room has no acceptance criteria.", back:"Back to overview", connected:"● Connected", offline:"○ Reconnecting", quiet:"The office is quiet. No agent is at work.", integrator:"Integrator", staff_verify:"Running verification", staff_review:"Reviewing", staff_wait:"Waiting for the other round", staff_finish:"Merging the worktree", staff_rest:"Resting in the lobby", waiting:"waiting for your answer", enter:"Enter room", close:"Close", previous:"Previous", next:"Next", page:"Page", details:"Details", progress:"Progress", verify:"Verify", review:"Review", pending:"Awaiting answer", task:"Task", status:"Status", phase:"Phase", host:"Host", now:"Now", owner:"Owner", currentWork:"Current work", question:"Pending question", questions:"Questions", workList:"Work list", clickWork:"Click for work", keyboardWork:"Press L for work list", keyboardPhase:"Press 1-5 for phase work", keyboardQuestions:"Press Q for questions", noPeople:"No known work is in this phase", noQuestions:"No pending questions", files:"Changed files", commands:"Commands", copy:"Copy", copied:"Copied", unknown:"unknown", unassigned:"unassigned", phases:{planning:"Planning", implementing:"Implementing", verifying:"Verifying", reviewing:"Reviewing", integrating:"Integrating", lobby:"Lobby", unknown:"Unassigned"}, statuses:{active:"active", idle:"idle", running:"running", reviewing:"reviewing", verifying:"verifying", blocked:"blocked", delivered:"delivered", complete:"complete", unknown:"unknown", unassigned:"unassigned", pending:"pending"}},
|
|
167
|
-
zh: {copyFailed:"手動複製", copyHint:"無法存取剪貼簿;請選取指令後手動複製。", checks:"驗收條件", unfinished:"尚未完成", sessionEnded:"工作階段已結束", attention:"需要介入", verifyFailed:"驗證未通過", reviewFailed:"審查未通過", blockedUnknown:"受阻;尚無原因資料", openQuestion:"查看問題與指令", detailsAction:"詳情與指令", stale:"重新連線中;顯示最後已知狀態", viewer:"你", overview:"辦公室總覽", rooms:"個房間", people:"位成員", recent:"最近完成", recentOn:"隱藏已完成", repos:"專案", allRepos:"所有專案", noTask:"無任務", noTaskHint:"沒有需要驗證或審查的工作", panel_task:"白板", panel_diff:"隔間螢幕", panel_verify:"QA 狀態看板", panel_review:"主管桌", panel_integration:"分信桌", noChanges:"目前沒有變更的檔案。", filesCount:"個檔案", notReviewed:"尚未審查。", firstRound:"第 1 輪", recheck:"對抗複查", blocking:"阻擋", refutedYes:"對抗複查推翻了第 1 輪。", refutedNo:"對抗複查維持第 1 輪的結論。", worktree:"Worktree", branch:"分支", base:"基準", ahead:"領先 commit", commitStep:"commit", finishStep:"worktree finish(合併)", hideList:"收合 »", showList:"« 工作清單", moveHint:"方向鍵或 WASD 走動;Enter 和最近的人對話;Esc 返回。", critPass:"通過", critFail:"未通過", critUnknown:"無法判定", critPending:"還沒驗證", noCriteria:"這間房沒有驗收條件。", back:"返回總覽", connected:"● 已連線", offline:"○ 重新連線中", quiet:"辦公室很安靜,目前沒有成員工作。", integrator:"整合", staff_verify:"執行驗證中", staff_review:"審查中", staff_wait:"等待另一輪審查", staff_finish:"合併 worktree 中", staff_rest:"在大廳休息", waiting:"等待你的回覆", enter:"進入房間", close:"關閉", previous:"上一頁", next:"下一頁", page:"頁", details:"詳細資料", progress:"進度", verify:"驗證", review:"審查", pending:"待回覆", task:"任務", status:"狀態", phase:"階段", host:"主機", now:"目前", owner:"負責人", currentWork:"目前工作", question:"待回覆問題", questions:"問題", workList:"工作清單", clickWork:"點擊查看工作", keyboardWork:"按 L 開啟工作清單", keyboardPhase:"按 1-5 查看階段工作", keyboardQuestions:"按 Q 查看問題", noPeople:"此階段目前沒有已知工作", noQuestions:"目前沒有待回覆問題", files:"變更檔案", commands:"指令", copy:"複製", copied:"已複製", unknown:"未知", unassigned:"未分配", phases:{planning:"規劃", implementing:"開發", verifying:"驗證", reviewing:"審查", integrating:"整合", lobby:"大廳", unknown:"未分配"}, statuses:{active:"進行中", idle:"閒置", running:"工作中", reviewing:"審查中", verifying:"驗證中", blocked:"受阻", delivered:"已交付", complete:"已完成", unknown:"未知", unassigned:"未分配", pending:"待回覆"}}
|
|
166
|
+
en: {copyFailed:"Copy manually", copyHint:"Clipboard unavailable; select the command and copy it manually.", checks:"Acceptance checks", unfinished:"Not complete", sessionEnded:"Session ended", attention:"Needs attention", verifyFailed:"Verification failed", reviewFailed:"Review failed", blockedUnknown:"Blocked; reason not provided", openQuestion:"View questions and commands", detailsAction:"Details and commands", stale:"Reconnecting; showing last known state", viewer:"You", overview:"Office overview", rooms:"rooms", people:"people", recent:"Recently completed", recentOn:"Hide completed", repos:"Repository", allRepos:"All repositories", noTask:"No task", noTaskHint:"Nothing to verify or review", panel_task:"Whiteboard", panel_diff:"Screen", panel_verify:"QA board", panel_review:"Review desk", panel_integration:"Sorting table", noChanges:"No changed files yet.", filesCount:"files", notReviewed:"Not reviewed yet.", firstRound:"Round 1", recheck:"Re-check", blocking:"blocking", refutedYes:"The re-check overturned round 1.", refutedNo:"The re-check upheld round 1.", worktree:"Worktree", branch:"Branch", base:"Base", ahead:"Commits ahead", commitStep:"Commit", finishStep:"worktree finish (merge)", hideList:"Hide list »", showList:"« Work list", moveHint:"Arrow keys or WASD walk; Enter talks to the nearest person; Esc returns.", critPass:"PASS", critFail:"FAIL", critUnknown:"Undetermined", critPending:"Not verified yet", noCriteria:"This room has no acceptance criteria.", back:"Back to overview", connected:"● Connected", offline:"○ Reconnecting", quiet:"The office is quiet. No agent is at work.", glance_pass:"Verify {n}/{t} passed", glance_fail:"Verify failed: {id}", glance_failed:"Verify failed", glance_pending:"{n} criteria still to verify", glance_all:"Criteria {n}/{t} passed", glance_review_pass:"Review passed ✓", glance_review_fail:"Review failed ✗", glance_wait_verify:"Still waiting for QA…", glance_wait_review:"Waiting for the review…", glance_wait_finish:"Waiting for the merge…", integrator:"Integrator", staff_verify:"Running verification", staff_review:"Reviewing", staff_wait:"Waiting for the other round", staff_finish:"Merging the worktree", staff_rest:"Resting in the lobby", waiting:"waiting for your answer", enter:"Enter room", close:"Close", previous:"Previous", next:"Next", page:"Page", details:"Details", progress:"Progress", verify:"Verify", review:"Review", pending:"Awaiting answer", task:"Task", status:"Status", phase:"Phase", host:"Host", now:"Now", owner:"Owner", currentWork:"Current work", question:"Pending question", questions:"Questions", workList:"Work list", clickWork:"Click for work", keyboardWork:"Press L for work list", keyboardPhase:"Press 1-5 for phase work", keyboardQuestions:"Press Q for questions", noPeople:"No known work is in this phase", noQuestions:"No pending questions", files:"Changed files", commands:"Commands", copy:"Copy", copied:"Copied", unknown:"unknown", unassigned:"unassigned", phases:{planning:"Planning", implementing:"Implementing", verifying:"Verifying", reviewing:"Reviewing", integrating:"Integrating", lobby:"Lobby", unknown:"Unassigned"}, statuses:{active:"active", idle:"idle", running:"running", reviewing:"reviewing", verifying:"verifying", blocked:"blocked", delivered:"delivered", complete:"complete", unknown:"unknown", unassigned:"unassigned", pending:"pending"}},
|
|
167
|
+
zh: {copyFailed:"手動複製", copyHint:"無法存取剪貼簿;請選取指令後手動複製。", checks:"驗收條件", unfinished:"尚未完成", sessionEnded:"工作階段已結束", attention:"需要介入", verifyFailed:"驗證未通過", reviewFailed:"審查未通過", blockedUnknown:"受阻;尚無原因資料", openQuestion:"查看問題與指令", detailsAction:"詳情與指令", stale:"重新連線中;顯示最後已知狀態", viewer:"你", overview:"辦公室總覽", rooms:"個房間", people:"位成員", recent:"最近完成", recentOn:"隱藏已完成", repos:"專案", allRepos:"所有專案", noTask:"無任務", noTaskHint:"沒有需要驗證或審查的工作", panel_task:"白板", panel_diff:"隔間螢幕", panel_verify:"QA 狀態看板", panel_review:"主管桌", panel_integration:"分信桌", noChanges:"目前沒有變更的檔案。", filesCount:"個檔案", notReviewed:"尚未審查。", firstRound:"第 1 輪", recheck:"對抗複查", blocking:"阻擋", refutedYes:"對抗複查推翻了第 1 輪。", refutedNo:"對抗複查維持第 1 輪的結論。", worktree:"Worktree", branch:"分支", base:"基準", ahead:"領先 commit", commitStep:"commit", finishStep:"worktree finish(合併)", hideList:"收合 »", showList:"« 工作清單", moveHint:"方向鍵或 WASD 走動;Enter 和最近的人對話;Esc 返回。", critPass:"通過", critFail:"未通過", critUnknown:"無法判定", critPending:"還沒驗證", noCriteria:"這間房沒有驗收條件。", back:"返回總覽", connected:"● 已連線", offline:"○ 重新連線中", quiet:"辦公室很安靜,目前沒有成員工作。", glance_pass:"驗證 {n}/{t} 通過", glance_fail:"驗證未通過:{id}", glance_failed:"驗證未通過", glance_pending:"待驗證 {n} 條", glance_all:"條件 {n}/{t} 通過", glance_review_pass:"審查通過 ✓", glance_review_fail:"審查未通過 ✗", glance_wait_verify:"還在等 QA…", glance_wait_review:"等審查中…", glance_wait_finish:"等整合中…", integrator:"整合", staff_verify:"執行驗證中", staff_review:"審查中", staff_wait:"等待另一輪審查", staff_finish:"合併 worktree 中", staff_rest:"在大廳休息", waiting:"等待你的回覆", enter:"進入房間", close:"關閉", previous:"上一頁", next:"下一頁", page:"頁", details:"詳細資料", progress:"進度", verify:"驗證", review:"審查", pending:"待回覆", task:"任務", status:"狀態", phase:"階段", host:"主機", now:"目前", owner:"負責人", currentWork:"目前工作", question:"待回覆問題", questions:"問題", workList:"工作清單", clickWork:"點擊查看工作", keyboardWork:"按 L 開啟工作清單", keyboardPhase:"按 1-5 查看階段工作", keyboardQuestions:"按 Q 查看問題", noPeople:"此階段目前沒有已知工作", noQuestions:"目前沒有待回覆問題", files:"變更檔案", commands:"指令", copy:"複製", copied:"已複製", unknown:"未知", unassigned:"未分配", phases:{planning:"規劃", implementing:"開發", verifying:"驗證", reviewing:"審查", integrating:"整合", lobby:"大廳", unknown:"未分配"}, statuses:{active:"進行中", idle:"閒置", running:"工作中", reviewing:"審查中", verifying:"驗證中", blocked:"受阻", delivered:"已交付", complete:"已完成", unknown:"未知", unassigned:"未分配", pending:"待回覆"}}
|
|
168
168
|
};
|
|
169
169
|
function getLanguage(){
|
|
170
170
|
try { var cookie = document.cookie.split(";").map(function(part){ return part.trim().split("="); }).find(function(pair){ return pair[0] === "agent-office-language"; }); if (cookie && (cookie[1] === "en" || cookie[1] === "zh")) return cookie[1]; } catch (_) {}
|
|
@@ -249,8 +249,57 @@ function roomBase(floor){
|
|
|
249
249
|
// ---- people: a slot in the room of their phase; they walk only when the phase changes ----
|
|
250
250
|
/** Engineers plan and build; once the work is with QA, the reviewers or the integrator, they stay at their cubicle. */
|
|
251
251
|
function roomFor(actor){ var room = ROOM_OF[actor.phase] || "lobby"; return room === "verifying" || room === "reviewing" || room === "integrating" ? "implementing" : room; }
|
|
252
|
-
// ---- resident staff: they
|
|
253
|
-
var STAFF_ROLES = ["qa", "reviewerA", "reviewerB", "integrator"], STAFF_FADE = 600;
|
|
252
|
+
// ---- resident staff: they idle in the lobby and walk to their station while their real process runs ----
|
|
253
|
+
var STAFF_ROLES = ["qa", "reviewerA", "reviewerB", "integrator"], STAFF_FADE = 600, staffSpots = {};
|
|
254
|
+
/** Frames a person stays at a lobby spot: 20 to 45 seconds at 60 frames a second, fixed by their key and visit. */
|
|
255
|
+
function dwellFrames(key, visit){ var h = 0, s = key + "#" + visit; for (var i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) >>> 0; return 1200 + h % 1501; }
|
|
256
|
+
function keyHash(s){ var h = 0; for (var i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) >>> 0; return h; }
|
|
257
|
+
/** Spots an idle engineer stands near are left to them. */
|
|
258
|
+
function engineerNear(floor, spot){
|
|
259
|
+
var place = placements(floor).byActor, lobby = layoutRoom("lobby");
|
|
260
|
+
return floor.actors.some(function(actor){ var p = place[actor.key]; if (!p || p.room !== "lobby") return false; var slot = lobby.slots[Math.min(p.index, lobby.slots.length - 1)]; return Math.abs(slot.feet.x - spot.feet.x) < 30 && Math.abs(slot.feet.y - spot.feet.y) < 30; });
|
|
261
|
+
}
|
|
262
|
+
/** The free lobby spots of a room, nearest to a point first. */
|
|
263
|
+
function freeSpots(floor, from, except){
|
|
264
|
+
var taken = {};
|
|
265
|
+
STAFF_ROLES.forEach(function(role){ var key = floor.key + "|staff:" + role, held = staffSpots[key]; if (key !== except && held && held.spot !== null) taken[held.spot] = true; });
|
|
266
|
+
return LAYOUT.staff.spots.map(function(spot, index){ return index; }).filter(function(index){ return !taken[index] && !engineerNear(floor, LAYOUT.staff.spots[index]); })
|
|
267
|
+
.sort(function(a, b){ var p = LAYOUT.staff.spots[a].feet, q = LAYOUT.staff.spots[b].feet; return (Math.abs(p.x - from.x) + Math.abs(p.y - from.y)) - (Math.abs(q.x - from.x) + Math.abs(q.y - from.y)) || a - b; });
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Where an idle staff member is: a reserved lobby spot. New rooms seat the four on different spots chosen from
|
|
271
|
+
* the room key, with staggered first moves; coming back from work takes the nearest free spot.
|
|
272
|
+
*/
|
|
273
|
+
function idleSpot(floor, key, index){
|
|
274
|
+
var held = staffSpots[key];
|
|
275
|
+
if (!held) {
|
|
276
|
+
var first = (keyHash(floor.key) + index * 3) % LAYOUT.staff.spots.length, free = freeSpots(floor, LAYOUT.staff.spots[first].feet, key);
|
|
277
|
+
held = staffSpots[key] = {spot: free.length ? free[0] : null, visit: 0, until: frame + 300 + index * 600};
|
|
278
|
+
} else if (held.spot === null) {
|
|
279
|
+
var state = people[key], near = freeSpots(floor, state ? {x: state.x, y: state.y} : LAYOUT.spawn, key);
|
|
280
|
+
held.spot = near.length ? near[0] : null; held.visit++; held.until = frame + dwellFrames(key, held.visit);
|
|
281
|
+
}
|
|
282
|
+
return held.spot === null ? LAYOUT.staff.spots[index] : LAYOUT.staff.spots[held.spot];
|
|
283
|
+
}
|
|
284
|
+
/** One idle person a room at a time, once their stay is over and no result bubble shows, moves to another free spot. */
|
|
285
|
+
function wander(floor){
|
|
286
|
+
if (reducedMotion) return;
|
|
287
|
+
var walking = false, ready = null;
|
|
288
|
+
STAFF_ROLES.forEach(function(role){
|
|
289
|
+
var key = floor.key + "|staff:" + role, state = people[key], held = staffSpots[key], memory = staffMemory[key];
|
|
290
|
+
if (state && state.path.length) walking = true;
|
|
291
|
+
var resting = held && held.spot !== null && !(memory && memory.busy) && !(memory && memory.result && frame < memory.result.until);
|
|
292
|
+
if (resting && frame >= held.until && (!ready || held.until < staffSpots[ready].until)) ready = key;
|
|
293
|
+
});
|
|
294
|
+
if (walking || !ready) return;
|
|
295
|
+
var held = staffSpots[ready], state = people[ready], here = LAYOUT.staff.spots[held.spot].feet;
|
|
296
|
+
var options = freeSpots(floor, here, ready).filter(function(index){ return index !== held.spot; });
|
|
297
|
+
held.visit++; held.until = frame + dwellFrames(ready, held.visit);
|
|
298
|
+
if (!options.length || !state) return;
|
|
299
|
+
held.spot = options[dwellFrames(ready, held.visit) % options.length];
|
|
300
|
+
var spot = LAYOUT.staff.spots[held.spot];
|
|
301
|
+
moveTo(ready, spot, spot.feet.x + "," + spot.feet.y, false);
|
|
302
|
+
}
|
|
254
303
|
function activityOf(floor, kind){ var list = floor.activities || []; for (var i = 0; i < list.length; i++) if (list[i].kind === kind) return list[i]; return null; }
|
|
255
304
|
/** Reviewer nameplates: the running review's targets, else the newest report's rounds, else A and B. */
|
|
256
305
|
function reviewerNames(floor){
|
|
@@ -268,9 +317,12 @@ function staffOf(floor){
|
|
|
268
317
|
var plans = {qa: ["verify", "QA", "qa", at.qa, "verify"], reviewerA: ["review", names[0], "reviewer", second ? at.armchair : at.desk, "review"],
|
|
269
318
|
reviewerB: ["review", names[1], "reviewer", second ? at.desk : at.armchair, "review"], integrator: ["finish", t("integrator"), "integrator", at.integrator, "integration"]};
|
|
270
319
|
return STAFF_ROLES.map(function(role, index){
|
|
271
|
-
var plan = plans[role], busy = !!activityOf(floor, plan[0]),
|
|
320
|
+
var plan = plans[role], busy = !!activityOf(floor, plan[0]), key = floor.key + "|staff:" + role;
|
|
321
|
+
// Work releases the lobby spot; the way back takes the nearest free one.
|
|
322
|
+
if (busy && staffSpots[key]) staffSpots[key].spot = null;
|
|
323
|
+
var slot = busy ? plan[3] : idleSpot(floor, key, index);
|
|
272
324
|
var narration = !busy ? t("staff_rest") : plan[0] !== "review" ? t("staff_" + plan[0]) : slot === at.desk ? t("staff_review") : t("staff_wait");
|
|
273
|
-
return {key:
|
|
325
|
+
return {key: key, id: plan[1], kind: plan[2], staff: role, activity: plan[0], panel: plan[4], busy: busy, slot: slot,
|
|
274
326
|
target: slot.feet.x + "," + slot.feet.y, narration: narration, questionCount: 0, status: busy ? "running" : "idle"};
|
|
275
327
|
});
|
|
276
328
|
}
|
|
@@ -293,14 +345,82 @@ function placements(floor){
|
|
|
293
345
|
});
|
|
294
346
|
return {byActor: byActor, counts: counts};
|
|
295
347
|
}
|
|
348
|
+
// ---- the engineer reads the whiteboard or the QA board when something worth reading happens ----
|
|
349
|
+
var GLANCE_STAY = 480, WAITING = {verifying: true, reviewing: true, integrating: true};
|
|
350
|
+
/** The one engineer a room's notices concern: a run's coordinator, or a session's engineer. */
|
|
351
|
+
function engineerOf(floor){ var lead = floor.actors.filter(function(a){ return a.kind === "coordinator"; })[0]; return lead || (floor.kind === "run" ? null : floor.actors[0] || null); }
|
|
352
|
+
function seatOf(floor, actor){ var p = placements(floor).byActor[actor.key], room = layoutRoom(p.room); return {slot: room.slots[Math.min(p.index, room.slots.length - 1)], target: p.room + ":" + p.index, hidden: p.index >= room.slots.length}; }
|
|
353
|
+
function glanceFacts(floor){ return {verify: floor.board.verify, review: floor.board.review, criteria: (floor.criteria || []).map(function(c){ return c.id + "=" + c.status; }).join(",")}; }
|
|
354
|
+
function fillIn(text, values){ return text.replace(/\{(\w+)\}/g, function(_, name){ return String(values[name]); }); }
|
|
355
|
+
/** What the engineer says at a notice: only what the snapshot shows. */
|
|
356
|
+
function glanceText(floor, reason){
|
|
357
|
+
var criteria = floor.criteria || [], passed = criteria.filter(function(c){ return c.status === "PASS"; }).length, pending = criteria.filter(function(c){ return !c.status; }).length;
|
|
358
|
+
if (reason === "verify") {
|
|
359
|
+
if (floor.board.verify === "PASS") return fillIn(t("glance_pass"), {n: floor.board.passed || passed, t: floor.board.total || criteria.length});
|
|
360
|
+
var failed = criteria.filter(function(c){ return c.status === "FAIL"; })[0];
|
|
361
|
+
return failed ? fillIn(t("glance_fail"), {id: failed.id}) : t("glance_failed");
|
|
362
|
+
}
|
|
363
|
+
if (reason === "review") return t(floor.board.review === "PASS" ? "glance_review_pass" : "glance_review_fail");
|
|
364
|
+
if (reason === "timer") { var busy = ["verify", "review", "finish"].filter(function(kind){ return activityOf(floor, kind); })[0]; if (busy) return t("glance_wait_" + busy); }
|
|
365
|
+
return pending ? fillIn(t("glance_pending"), {n: pending}) : fillIn(t("glance_all"), {n: passed, t: criteria.length});
|
|
366
|
+
}
|
|
367
|
+
function glanceTimer(key, visit){ return 2400 + dwellFrames(key, visit) % 3001; }
|
|
368
|
+
/** Turns snapshot changes into a reading trip: the QA board first, then the whiteboard. The first snapshot is only a baseline. */
|
|
369
|
+
function noticeChanges(floor){
|
|
370
|
+
var facts = glanceFacts(floor), state = glances[floor.key];
|
|
371
|
+
if (!state) { glances[floor.key] = {facts: facts, stops: [], arrived: null, visit: 0, next: frame + glanceTimer(floor.key, 0), bubble: null}; return; }
|
|
372
|
+
var before = state.facts, stops = [];
|
|
373
|
+
state.facts = facts;
|
|
374
|
+
var settled = function(value){ return value === "PASS" || value === "FAIL"; };
|
|
375
|
+
if (facts.verify !== before.verify && settled(facts.verify)) stops.push({spot: "status", reason: "verify"});
|
|
376
|
+
if (facts.review !== before.review && settled(facts.review)) stops.push({spot: "board", reason: "review"});
|
|
377
|
+
else if (facts.criteria !== before.criteria) stops.push({spot: "board", reason: "criteria"});
|
|
378
|
+
var engineer = engineerOf(floor);
|
|
379
|
+
if (!stops.length || !engineer || engineer.questionCount > 0) return;
|
|
380
|
+
if (reducedMotion) { state.bubble = {key: engineer.key, text: glanceText(floor, stops[0].reason), until: frame + GLANCE_STAY}; return; }
|
|
381
|
+
stops.forEach(function(stop){ if (!state.stops.some(function(s){ return s.spot === stop.spot; })) state.stops.push(stop); });
|
|
382
|
+
}
|
|
383
|
+
/** Moves the engineer through their stops, waits at each, then back to the seat; between events they now and then look at the whiteboard. */
|
|
384
|
+
function glanceStep(floor){
|
|
385
|
+
var state = glances[floor.key], engineer = engineerOf(floor);
|
|
386
|
+
if (!state || !engineer || reducedMotion) return;
|
|
387
|
+
var person = people[engineer.key];
|
|
388
|
+
if (!person) return;
|
|
389
|
+
if (engineer.questionCount > 0) { if (state.stops.length) { state.stops = []; state.arrived = null; var home = seatOf(floor, engineer); moveTo(engineer.key, home.slot, home.target, home.hidden); } return; }
|
|
390
|
+
if (!state.stops.length) {
|
|
391
|
+
if (WAITING[floor.phase] && frame >= state.next) { state.visit++; state.next = frame + glanceTimer(floor.key, state.visit); state.stops.push({spot: "board", reason: "timer"}); }
|
|
392
|
+
else return;
|
|
393
|
+
}
|
|
394
|
+
var stop = state.stops[0], spot = LAYOUT.look[stop.spot], target = "look:" + stop.spot;
|
|
395
|
+
if (person.target !== target) { moveTo(engineer.key, spot, target, false); state.arrived = null; return; }
|
|
396
|
+
if (person.path.length) return;
|
|
397
|
+
if (state.arrived === null) { state.arrived = frame; stop.text = glanceText(floor, stop.reason); }
|
|
398
|
+
if (frame - state.arrived < GLANCE_STAY) return;
|
|
399
|
+
state.stops.shift(); state.arrived = null;
|
|
400
|
+
if (state.stops.length) { glanceStep(floor); return; }
|
|
401
|
+
var seat = seatOf(floor, engineer);
|
|
402
|
+
moveTo(engineer.key, seat.slot, seat.target, seat.hidden);
|
|
403
|
+
state.next = Math.max(state.next, frame + glanceTimer(floor.key, state.visit + 1));
|
|
404
|
+
}
|
|
405
|
+
/** The engineer's bubble while reading, or at the seat under reduced motion. */
|
|
406
|
+
function glanceBubble(floor){
|
|
407
|
+
var state = glances[floor.key], engineer = engineerOf(floor);
|
|
408
|
+
if (!state || !engineer) return null;
|
|
409
|
+
if (state.bubble && frame < state.bubble.until && state.bubble.key === engineer.key) return {key: engineer.key, text: state.bubble.text};
|
|
410
|
+
var stop = state.stops[0];
|
|
411
|
+
return stop && state.arrived !== null && stop.text ? {key: engineer.key, text: stop.text} : null;
|
|
412
|
+
}
|
|
296
413
|
function syncPeople(){
|
|
297
414
|
if (!model) return;
|
|
298
415
|
var seen = {};
|
|
299
416
|
model.floors.forEach(function(floor){
|
|
300
417
|
var place = placements(floor).byActor;
|
|
418
|
+
noticeChanges(floor);
|
|
419
|
+
var reading = glances[floor.key] && glances[floor.key].stops.length ? engineerOf(floor) : null;
|
|
301
420
|
floor.actors.forEach(function(actor){
|
|
302
421
|
var p = place[actor.key], room = layoutRoom(p.room);
|
|
303
422
|
seen[actor.key] = true;
|
|
423
|
+
if (reading && reading.key === actor.key && people[actor.key]) return; // on a reading trip; glanceStep brings them back
|
|
304
424
|
moveTo(actor.key, room.slots[Math.min(p.index, room.slots.length - 1)], p.room + ":" + p.index, p.index >= room.slots.length);
|
|
305
425
|
});
|
|
306
426
|
staffOf(floor).forEach(function(member){
|
|
@@ -314,6 +434,8 @@ function syncPeople(){
|
|
|
314
434
|
});
|
|
315
435
|
Object.keys(people).forEach(function(key){ if (!seen[key]) delete people[key]; });
|
|
316
436
|
Object.keys(staffMemory).forEach(function(key){ if (!seen[key]) delete staffMemory[key]; });
|
|
437
|
+
Object.keys(staffSpots).forEach(function(key){ if (!seen[key]) delete staffSpots[key]; });
|
|
438
|
+
Object.keys(glances).forEach(function(key){ if (!roomByKey(key)) delete glances[key]; });
|
|
317
439
|
}
|
|
318
440
|
function stepAlong(state){
|
|
319
441
|
if (!state.path.length) return;
|
|
@@ -563,10 +685,12 @@ function renderLabels(floor, figs){
|
|
|
563
685
|
figs.forEach(function(f){ label("name:" + f.actor.key, "nameplate" + (f.actor.questionCount ? " alert" : ""), shortName(f.actor.id, 14), f.x + 17, f.y + 50); });
|
|
564
686
|
label("name:viewer", "nameplate viewer", t("viewer"), viewer.x, viewer.y + 1);
|
|
565
687
|
var near = nearestActor(figs);
|
|
566
|
-
|
|
688
|
+
var said = glanceBubble(floor), reader = said && figs.filter(function(f){ return f.actor.key === said.key; })[0];
|
|
689
|
+
if (reader) label("glance", "speech glance", said.text, reader.x + 17, reader.y - 6);
|
|
690
|
+
if (near && near !== reader) label("speech", "speech", shortName(near.actor.id, 14) + ": " + localizeText(near.actor.narration), near.x + 17, near.y - 6);
|
|
567
691
|
}
|
|
568
692
|
function render(){ if (!model) return; if (mode === "room") renderRoomView(); else renderOverview(); }
|
|
569
|
-
function tick(){ frame++; if (!reducedMotion) advance(); render(); }
|
|
693
|
+
function tick(){ frame++; if (!reducedMotion) { if (model) model.floors.forEach(function(floor){ wander(floor); glanceStep(floor); }); advance(); } render(); }
|
|
570
694
|
function attentionFor(actor){
|
|
571
695
|
if(actor.questionCount)return t('waiting')+' ('+actor.questionCount+')';
|
|
572
696
|
if(actor.progress&&actor.progress.verify==='FAIL')return t('verifyFailed');
|
|
@@ -106,13 +106,22 @@ export function sceneModel(snapshot) {
|
|
|
106
106
|
verify = "PASS";
|
|
107
107
|
if (review === null && run.agents.length && run.agents.every(agent => agent.progress?.review === "PASS"))
|
|
108
108
|
review = "PASS";
|
|
109
|
-
const rootProgress = { passed, total, verify, review };
|
|
110
|
-
const
|
|
109
|
+
const rootProgress = Object.prototype.hasOwnProperty.call(extra, "progress") ? progressOf(extra.progress) : { passed, total, verify, review };
|
|
110
|
+
const criteria = (Array.isArray(extra.criteria) ? extra.criteria : []).map(value => {
|
|
111
|
+
const item = optional(value);
|
|
112
|
+
return { id: typeof item.id === "string" ? item.id : "unknown", description: typeof item.description === "string" ? item.description : "",
|
|
113
|
+
status: item.status === "PASS" || item.status === "FAIL" || item.status === "UNKNOWN" ? item.status : null,
|
|
114
|
+
finishedAt: typeof item.finishedAt === "string" ? item.finishedAt : null,
|
|
115
|
+
failureClass: typeof item.failureClass === "string" ? item.failureClass : null,
|
|
116
|
+
exitCode: typeof item.exitCode === "number" ? item.exitCode : null, output: typeof item.output === "string" ? item.output : null };
|
|
117
|
+
});
|
|
118
|
+
const taskId = typeof extra.taskId === "string" && extra.taskId.length > 0
|
|
119
|
+
? extra.taskId : run.agents.find(agent => agent.role === "coordinator")?.taskId ?? "unassigned";
|
|
111
120
|
if (questions.length > 0)
|
|
112
121
|
dialogue.push(`${run.title}: waiting for your answer — ${questions[0].prompt}`);
|
|
113
|
-
return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria
|
|
122
|
+
return { key, sourceIndex: index, kind: "run", title: titled(repo, run.title), top: 0, props, actors, phaseAreas: makeAreas(), questions, criteria,
|
|
114
123
|
activities: activitiesOf(optional(run).activities),
|
|
115
|
-
board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: passed, total },
|
|
124
|
+
board: boardFor(rootProgress, run.status, taskId, questions.length), papers, books: { lit: rootProgress?.passed ?? 0, total: rootProgress?.total ?? 0 },
|
|
116
125
|
clock: run.budget.limitMs > 0 ? run.budget.remainingMs / run.budget.limitMs : 0, phase: run.phase, status: run.status,
|
|
117
126
|
completedAt, repo, overview: { x: 0, y: 0, width: 0, height: 0 } };
|
|
118
127
|
};
|
|
@@ -29,6 +29,13 @@ export function buildOfficeSnapshot(input) {
|
|
|
29
29
|
const reviewers = input.reviews.filter(r => r.root !== null && roots.has(r.root)).slice(0, 2);
|
|
30
30
|
reviewers.forEach(r => claimed.add(r));
|
|
31
31
|
const usedMs = activeWallTimeMs(run.budget.activeIntervals, input.now);
|
|
32
|
+
const coordinator = run.workers.find(worker => worker.workerId === run.coordinatorId);
|
|
33
|
+
// rootTaskId is the persisted run authority. Only legacy runs without it
|
|
34
|
+
// fall back to the coordinator's task id.
|
|
35
|
+
const rootTaskId = run.rootTaskId ?? (coordinator?.taskId || null);
|
|
36
|
+
const rootTask = rootTaskId === null ? undefined : run.tasks.find(task => task.taskId === rootTaskId);
|
|
37
|
+
const rootTree = rootTaskId === null ? undefined : input.worktrees.find(tree => tree.taskId === rootTaskId &&
|
|
38
|
+
(tree.runId === run.runId || tree.path === coordinator?.worktree));
|
|
32
39
|
const agents = run.workers.filter(w => w.workerId === run.coordinatorId || LIVE.has(w.status)).map((w) => {
|
|
33
40
|
const tree = w.worktree === null ? undefined : byPath.get(w.worktree);
|
|
34
41
|
const phase = w.phase ?? "unknown";
|
|
@@ -39,6 +46,11 @@ export function buildOfficeSnapshot(input) {
|
|
|
39
46
|
});
|
|
40
47
|
const questions = run.questions.filter(q => q.answeredAt === null).map(q => ({ questionId: q.questionId, prompt: q.prompt }));
|
|
41
48
|
return { runId: run.runId, title: run.rootTaskId ?? run.runId,
|
|
49
|
+
...(rootTaskId === null ? {} : { taskId: rootTaskId }),
|
|
50
|
+
// The root task is the only source of run-level proof. A
|
|
51
|
+
// missing proof stays null; worker progress must never become root proof.
|
|
52
|
+
...(rootTaskId === null ? {} : { progress: rootTree?.progress ?? rootTask?.progress ?? null,
|
|
53
|
+
...(rootTree?.criteria === undefined ? {} : { criteria: rootTree.criteria }) }),
|
|
42
54
|
status: run.status, phase: run.phase ?? "unknown",
|
|
43
55
|
budget: { limitMs: run.budget.limitMs, usedMs, remainingMs: Math.max(0, run.budget.limitMs - usedMs) },
|
|
44
56
|
questions, agents, reviewers,
|
|
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { sha256 } from "../fs/hash.js";
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
5
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
6
|
+
import { FileRunRepository } from "../run/service.js";
|
|
6
7
|
import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, assertRunWorktreeOwnership, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
|
|
7
8
|
/**
|
|
8
9
|
* A baseline no source state hashes to. A resumed session inherits whatever
|
|
@@ -141,8 +142,11 @@ export async function resumeWorktree(deps, options) {
|
|
|
141
142
|
const sessionId = assertSessionId(options.sessionId);
|
|
142
143
|
const mainRoot = await mainCheckout(deps, options.cwd);
|
|
143
144
|
const previous = await requireRecord(mainRoot, name);
|
|
144
|
-
|
|
145
|
+
// A base gives the service repository context, which debug resume needs.
|
|
146
|
+
const tasks = deps.tasks(previous.path, previous.base);
|
|
145
147
|
const attached = await tasks.status({ sessionId: previous.sessionId }).catch(() => null);
|
|
148
|
+
if (attached?.task.debug !== undefined)
|
|
149
|
+
await tasks.debugService().resume(attached.task.id, sessionId);
|
|
146
150
|
if (attached !== null)
|
|
147
151
|
await tasks.attach(sessionId, attached.task.id);
|
|
148
152
|
const record = { ...previous, sessionId };
|
|
@@ -206,6 +210,15 @@ export async function removeWorktree(deps, options) {
|
|
|
206
210
|
const name = assertWorktreeName(options.name);
|
|
207
211
|
const mainRoot = await mainCheckout(deps, options.cwd);
|
|
208
212
|
const status = await describe(deps, await requireRecord(mainRoot, name));
|
|
213
|
+
// A running run still writes here; --force discards work, it does not stop the run.
|
|
214
|
+
const runId = status.record.runId;
|
|
215
|
+
if (runId !== undefined) {
|
|
216
|
+
const commonDir = join(mainRoot, ".git");
|
|
217
|
+
const run = await new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir).read(runId).catch(() => null);
|
|
218
|
+
if (run !== null && ["active", "awaiting-input", "stopping"].includes(run.status)) {
|
|
219
|
+
throw manageError("WORKTREE_RUN_ACTIVE", `${name} belongs to run ${runId}, which is ${run.status}. Stop it first: agent-ops run stop ${runId}.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
209
222
|
if ((status.dirty || status.ahead > 0) && !options.force) {
|
|
210
223
|
throw manageError("WORKTREE_UNMERGED", `${name} has ${status.dirty ? "uncommitted changes" : ""}${status.dirty && status.ahead > 0 ? " and " : ""}${status.ahead > 0 ? `${status.ahead} commit(s) not in ${status.record.targetBranch}` : ""}. Finish it, or remove it with --force to discard that work (the user is asked).`);
|
|
211
224
|
}
|
|
@@ -217,7 +230,11 @@ export async function removeWorktree(deps, options) {
|
|
|
217
230
|
? mainGate.redirect(record.sessionId, null)
|
|
218
231
|
: mainGate.removeRoot(record.sessionId, record.path));
|
|
219
232
|
}
|
|
220
|
-
|
|
233
|
+
// A paused or stopped run's worktree has no loadable policy config; its trust
|
|
234
|
+
// was never this checkout's to revoke, so removal must not depend on it.
|
|
235
|
+
const config = await deps.loadConfig(record.path).catch(() => null);
|
|
236
|
+
if (config !== null)
|
|
237
|
+
await deps.trust.revoke(record.path, config).catch(() => undefined);
|
|
221
238
|
await removeCheckout(deps, record, true);
|
|
222
239
|
return status;
|
|
223
240
|
}
|
|
@@ -17,7 +17,9 @@ const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
|
17
17
|
const REPORT_ARTIFACT_MAX_BYTES = 512 * 1024;
|
|
18
18
|
function validContractBinding(value) {
|
|
19
19
|
const hashes = [value.taskContractHash, value.goalHash, value.coverageDigest];
|
|
20
|
-
return
|
|
20
|
+
return (value.debugProofs === undefined || typeof value.debugProofs === "object" && value.debugProofs !== null && !Array.isArray(value.debugProofs) &&
|
|
21
|
+
Object.entries(value.debugProofs).every(([id, hash]) => TASK_ID_PATTERN.test(id) && typeof hash === "string" && DIGEST_PATTERN.test(hash))) &&
|
|
22
|
+
hashes.every(v => typeof v === "string" && DIGEST_PATTERN.test(v)) &&
|
|
21
23
|
(value.treeContractHash === undefined || typeof value.treeContractHash === "string" && DIGEST_PATTERN.test(value.treeContractHash)) &&
|
|
22
24
|
typeof value.candidateCommit === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value.candidateCommit) &&
|
|
23
25
|
typeof value.taskContracts === "object" && value.taskContracts !== null && !Array.isArray(value.taskContracts) &&
|
|
@@ -26,6 +28,7 @@ function validContractBinding(value) {
|
|
|
26
28
|
}
|
|
27
29
|
function contractBinding(value) {
|
|
28
30
|
return validContractBinding(value) ? {
|
|
31
|
+
...(value.debugProofs === undefined ? {} : { debugProofs: value.debugProofs }),
|
|
29
32
|
taskContractHash: value.taskContractHash, taskContracts: value.taskContracts,
|
|
30
33
|
...(value.treeContractHash === undefined ? {} : { treeContractHash: value.treeContractHash }),
|
|
31
34
|
goalHash: value.goalHash, candidateCommit: value.candidateCommit, coverageDigest: value.coverageDigest
|
|
@@ -705,7 +705,7 @@ export function createReviewExecutor(options) {
|
|
|
705
705
|
const clock = options.now ?? (() => Date.now());
|
|
706
706
|
return async (request) => {
|
|
707
707
|
const deadlineAt = clock() +
|
|
708
|
-
(options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS);
|
|
708
|
+
Math.min(options.chainTimeoutMs ?? DEFAULT_REVIEW_CHAIN_TIMEOUT_MS, await options.remainingBudgetMs?.() ?? Infinity);
|
|
709
709
|
const remainingMs = () => deadlineAt - clock();
|
|
710
710
|
/**
|
|
711
711
|
* The options one stage runs under: its own timeout, or what is left of
|
|
@@ -48,62 +48,77 @@ export async function withReviewSlot(options, action) {
|
|
|
48
48
|
}
|
|
49
49
|
const sleep = options.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
|
|
50
50
|
let waiting = false;
|
|
51
|
+
let waitEnded = false;
|
|
51
52
|
let held;
|
|
52
53
|
const token = randomUUID();
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
54
|
+
try {
|
|
55
|
+
while (held === undefined) {
|
|
56
|
+
if (options.signal?.aborted === true)
|
|
57
|
+
throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
|
|
58
|
+
for (let index = 0; index < width && held === undefined; index += 1) {
|
|
59
|
+
const path = join(options.dir, `agent-ops-review-slot-${index}.lock`);
|
|
60
|
+
try {
|
|
61
|
+
const claimed = await withPrivateFileLock(path + ".state", options.dir, async () => {
|
|
62
|
+
if (options.signal?.aborted === true)
|
|
63
|
+
throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
|
|
64
|
+
for (;;) {
|
|
65
|
+
try {
|
|
66
|
+
await mkdir(path);
|
|
67
|
+
await writeFile(join(path, "owner.json"), JSON.stringify({ ...options.holder, pid: process.pid, at: Date.now(), token }));
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== "EEXIST")
|
|
72
|
+
throw error;
|
|
73
|
+
if (!await stale(path, options.staleMs ?? STALE_MS))
|
|
74
|
+
return false;
|
|
75
|
+
await rm(path, { recursive: true, force: true });
|
|
76
|
+
}
|
|
74
77
|
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
});
|
|
79
|
+
if (claimed)
|
|
80
|
+
held = path;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
79
86
|
}
|
|
80
|
-
|
|
81
|
-
if (!
|
|
82
|
-
|
|
87
|
+
if (held === undefined) {
|
|
88
|
+
if (!waiting) {
|
|
89
|
+
waiting = true;
|
|
90
|
+
await options.onWaitStart?.();
|
|
91
|
+
options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
|
|
92
|
+
}
|
|
93
|
+
await sleep(options.pollMs ?? POLL_MS);
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
options.onWait?.(`review: waiting for one of ${String(width)} shared review slots`);
|
|
89
|
-
}
|
|
90
|
-
await sleep(options.pollMs ?? POLL_MS);
|
|
96
|
+
if (waiting) {
|
|
97
|
+
waitEnded = true;
|
|
98
|
+
await options.onWaitEnd?.(true);
|
|
91
99
|
}
|
|
92
|
-
}
|
|
93
|
-
try {
|
|
94
100
|
if (options.signal?.aborted === true)
|
|
95
101
|
throw new ReviewInterruptedError(String(options.signal.reason ?? ""));
|
|
96
102
|
return await action();
|
|
97
103
|
}
|
|
98
104
|
finally {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
if (waiting && !waitEnded) {
|
|
106
|
+
waitEnded = true;
|
|
107
|
+
try {
|
|
108
|
+
await options.onWaitEnd?.(false);
|
|
109
|
+
}
|
|
110
|
+
catch { /* preserve the original wait/abort failure */ }
|
|
111
|
+
}
|
|
112
|
+
if (held !== undefined) {
|
|
113
|
+
await withPrivateFileLock(held + ".state", options.dir, async () => {
|
|
114
|
+
const owner = await readFile(join(held, "owner.json"), "utf8").catch(error => {
|
|
115
|
+
if (error.code === "ENOENT")
|
|
116
|
+
return null;
|
|
117
|
+
throw error;
|
|
118
|
+
});
|
|
119
|
+
if (owner !== null && JSON.parse(owner)?.token === token)
|
|
120
|
+
await rm(held, { recursive: true, force: true });
|
|
104
121
|
});
|
|
105
|
-
|
|
106
|
-
await rm(held, { recursive: true, force: true });
|
|
107
|
-
});
|
|
122
|
+
}
|
|
108
123
|
}
|
|
109
124
|
}
|
|
@@ -2,10 +2,13 @@ import { nativeProcessGroupAlive, signalNativeProcessGroup } from "./hosts/util.
|
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
+
import { readWindowsProcessIdentity } from "../security/permissions.js";
|
|
5
6
|
const exec = promisify(execFile);
|
|
6
7
|
export async function nativeProcessIdentity(pid) {
|
|
7
8
|
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
8
9
|
return null;
|
|
10
|
+
if (process.platform === "win32")
|
|
11
|
+
return await readWindowsProcessIdentity(pid);
|
|
9
12
|
try {
|
|
10
13
|
const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="], { maxBuffer: 8192 });
|
|
11
14
|
return stdout.trim() || null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validDebugSettings, validDebugIntake } from "../task/debug-state.js";
|
|
1
2
|
import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
2
3
|
import { acceptanceError, runnerError, commitIdentity } from "./acceptance.js";
|
|
3
4
|
const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
|
|
@@ -29,6 +30,7 @@ const HOOK_EVENT_VALUES = new Set([
|
|
|
29
30
|
"SubagentStart",
|
|
30
31
|
"SubagentStop",
|
|
31
32
|
"Stop",
|
|
33
|
+
"Interrupt",
|
|
32
34
|
"SessionEnd"
|
|
33
35
|
]);
|
|
34
36
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
@@ -331,10 +333,12 @@ export function validateConfig(value) {
|
|
|
331
333
|
if (!isRecord(root.features)) {
|
|
332
334
|
return failure("INVALID_TYPE", "$.features", "features must be an object.");
|
|
333
335
|
}
|
|
334
|
-
const featuresUnknown = unknownFieldFailure(root.features, ["completionGate", "stopVerification", "office"], "$.features");
|
|
336
|
+
const featuresUnknown = unknownFieldFailure(root.features, ["completionGate", "stopVerification", "office", "debug"], "$.features");
|
|
335
337
|
if (featuresUnknown !== undefined) {
|
|
336
338
|
return featuresUnknown;
|
|
337
339
|
}
|
|
340
|
+
if (root.features.debug !== undefined && !validDebugSettings(root.features.debug))
|
|
341
|
+
return failure("INVALID_FEATURE", "$.features.debug", "Debug requires enabled, bounded budgetMs and maxRounds.");
|
|
338
342
|
if (root.features.office !== undefined) {
|
|
339
343
|
if (!isRecord(root.features.office)) {
|
|
340
344
|
return failure("INVALID_TYPE", "$.features.office", "office must be an object.");
|
|
@@ -566,10 +570,12 @@ export function validateCriterion(value, path) {
|
|
|
566
570
|
return success(value);
|
|
567
571
|
}
|
|
568
572
|
export function validateTask(value) {
|
|
569
|
-
const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
|
|
573
|
+
const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title", "debug"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
|
|
570
574
|
if (isFailure(root)) {
|
|
571
575
|
return root;
|
|
572
576
|
}
|
|
577
|
+
if (root.debug !== undefined && (!validDebugIntake(root.debug) || root.schemaVersion !== TASK_SCHEMA_VERSION || root.goal === undefined))
|
|
578
|
+
return failure("INVALID_DEBUG_TASK", "$.debug", "Debug tasks require immutable goal and bounded root intake.");
|
|
573
579
|
if (root.contractRevision !== undefined && (!Number.isSafeInteger(root.contractRevision) || root.contractRevision < 0))
|
|
574
580
|
return failure("INVALID_CONTRACT_REVISION", "$.contractRevision", "Invalid contract revision.");
|
|
575
581
|
if (root.goal !== undefined && (!isNonEmptyString(root.goal) || Buffer.byteLength(root.goal, "utf8") > 65536))
|
|
@@ -232,7 +232,9 @@ async function readPosixProcessIdentity(processId) {
|
|
|
232
232
|
return null;
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
|
-
async function readWindowsProcessIdentity(processId) {
|
|
235
|
+
export async function readWindowsProcessIdentity(processId) {
|
|
236
|
+
if (!Number.isSafeInteger(processId) || processId < 1)
|
|
237
|
+
return null;
|
|
236
238
|
try {
|
|
237
239
|
const script = [
|
|
238
240
|
`$process = Get-Process -Id ${processId} -ErrorAction Stop`,
|
|
@@ -3,12 +3,31 @@ import { findReviewAttestation } from "../review/attestation.js";
|
|
|
3
3
|
import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
|
|
4
4
|
import { isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
5
5
|
import { taskContractHash } from "./contract.js";
|
|
6
|
-
import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
|
|
6
|
+
import { acceptanceCoverage, coverageDigest } from "../verify/acceptance-coverage.js";
|
|
7
|
+
import { loadDebugProof, debugProofDigest, debugRemainingMs } from "./debug-state.js";
|
|
7
8
|
export async function checkTaskCompletionEvidence(stored, options) {
|
|
9
|
+
const debug = stored.task.debug === undefined ? undefined : await loadDebugProof(options.root, stored.task.debug.rootTaskId);
|
|
10
|
+
if (debug?.control?.paused === true || debug?.control?.mode === "diagnose")
|
|
11
|
+
return { status: "FAIL", code: "TASK_DEBUG_SCOPE_RESTRICTED", remedy: "The human restricted this issue to pause or diagnosis. Preserve the active task; only a native human resume repair event authorizes repair completion." };
|
|
8
12
|
const problem = await checkTaskVerificationEvidence(stored, options);
|
|
9
13
|
if (problem !== null)
|
|
10
14
|
return problem;
|
|
11
15
|
const attestation = await findReviewAttestation(options.root, options.sourceFingerprint, stored.task.id);
|
|
16
|
+
if (debug !== undefined) {
|
|
17
|
+
const coverage = await acceptanceCoverage(stored, options.config, options.sourceFingerprint, options.evidenceStore);
|
|
18
|
+
const { FileTaskStore } = await import("./store.js");
|
|
19
|
+
const { join } = await import("node:path");
|
|
20
|
+
const reviewedRecords = (await new FileTaskStore(join(options.root, ".agent-ops/tasks/state.json"), options.root).read()).tasks.filter(r => attestation?.taskContracts?.[r.task.id] !== undefined);
|
|
21
|
+
const currentCoverage = [];
|
|
22
|
+
for (const record of reviewedRecords) {
|
|
23
|
+
const rows = await acceptanceCoverage(record, options.config, options.sourceFingerprint, options.evidenceStore);
|
|
24
|
+
currentCoverage.push(...rows.map(row => ({ ...row, criterionId: attestation?.tree === undefined ? row.criterionId : record.task.id + ":" + row.criterionId })));
|
|
25
|
+
}
|
|
26
|
+
const refs = new Set(coverage.filter(c => c.mode === "behavioral" && c.status === "proven").flatMap(c => c.evidenceReferences));
|
|
27
|
+
if (attestation?.coverageDigest !== coverageDigest(currentCoverage) || debug.operation !== null || debugRemainingMs(debug) === 0 || !debug.hypotheses.some(h => h.status === "confirmed" && h.supporting.some(ref => refs.has(ref))) ||
|
|
28
|
+
attestation?.debugProofs?.[stored.task.id] !== debugProofDigest(debug))
|
|
29
|
+
return { status: "FAIL", code: "TASK_DEBUG_PROOF_REQUIRED", remedy: "Confirm the original cause with current discriminating regression proof, then obtain two fresh independent reviews bound to that debug proof." };
|
|
30
|
+
}
|
|
12
31
|
if (attestation === null || attestation.taskId !== stored.task.id ||
|
|
13
32
|
((stored.task.criteria.some(c => c.acceptance !== undefined) || (stored.task.contractRevision ?? 0) > 0 || stored.task.goal !== undefined) &&
|
|
14
33
|
(attestation.schemaVersion !== 3 || attestation.taskContracts?.[stored.task.id] !== taskContractHash(stored.task))) ||
|