diff --git a/README.md b/README.md index 6182d8d..465317d 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,11 @@ After `./scripts/setup.sh`, recommended one-shot startup: Use this when you want the full tmux-backed operator workflow, browser PTT, terminal mirror, hidden mobile recovery, and the safest default bridge wiring. - `run-operator-once.sh` / `run-operator-stack.sh` launch `face-app`, and `face-app` starts `tts-worker` by default unless `FACE_TTS_ENABLED=0` is set. `qwen3` / `qwen3-realtime` profiles work by passing `TTS_ENGINE=qwen3` into that spawned worker path. +- Profile shorthand: + - `--profile default`: Kokoro TTS + batch ASR only + - `--profile realtime`: Kokoro TTS + Voxtral realtime ASR + Parakeet fallback + - `--profile qwen3`: Qwen3 TTS + batch ASR only + - `--profile qwen3-realtime`: Qwen3 TTS + Voxtral realtime ASR + Parakeet fallback - When you use this app to work on another repository, put a project-local `AGENTS.md` in that target repository too. Start from `doc/examples/AGENTS.sample.md`, then customize the repo-specific build/test/run rules there. - For another repository, you can start the operator in either of these equivalent styles: - run from this repository and pass `--repo /path/to/target-repo` @@ -606,6 +611,11 @@ tailscale serve --bg 8765 これは、tmux 連携、browser PTT、terminal mirror、隠し復旧、bridge の安全な既定配線まで含む、いちばん実用的な構成です。 - `run-operator-once.sh` / `run-operator-stack.sh` は `face-app` を起動し、その `face-app` が既定で `tts-worker` を子起動します。`FACE_TTS_ENABLED=0` を指定しない限り、別ターミナルでの TTS 起動は不要です。`qwen3` / `qwen3-realtime` profile は、この子起動 worker に `TTS_ENGINE=qwen3` を渡して切り替えます。 +- profile の意味: + - `--profile default`: Kokoro TTS + batch ASR のみ + - `--profile realtime`: Kokoro TTS + Voxtral realtime ASR + Parakeet fallback + - `--profile qwen3`: Qwen3 TTS + batch ASR のみ + - `--profile qwen3-realtime`: Qwen3 TTS + Voxtral realtime ASR + Parakeet fallback - このアプリを使って別の作業リポジトリを扱う場合は、その target repository 側にも project-local な `AGENTS.md` を置いてください。`doc/examples/AGENTS.sample.md` を出発点にして、その repo 固有の build/test/run ルールを追記するのが簡単です。 - 別の作業リポジトリで使う起動方法は、次の 2 通りが実用的です。 - このリポジトリ側から `--repo /path/to/target-repo` を付けて起動する diff --git a/asr-worker/pyproject.toml b/asr-worker/pyproject.toml index fb505f1..625b1c2 100644 --- a/asr-worker/pyproject.toml +++ b/asr-worker/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "asr-worker" -version = "1.11.0" +version = "1.12.0" description = "Local ASR worker for english-trainer (Parakeet EN/JA routing)" readme = "README.md" requires-python = ">=3.10" diff --git a/asr-worker/uv.lock b/asr-worker/uv.lock index f28e620..4043c5a 100644 --- a/asr-worker/uv.lock +++ b/asr-worker/uv.lock @@ -247,7 +247,7 @@ wheels = [ [[package]] name = "asr-worker" -version = "1.11.0" +version = "1.12.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, diff --git a/doc/examples/AGENTS.sample.md b/doc/examples/AGENTS.sample.md index 295f865..71bb1e7 100644 --- a/doc/examples/AGENTS.sample.md +++ b/doc/examples/AGENTS.sample.md @@ -30,6 +30,10 @@ Paste this into your project `AGENTS.md` and customize. - Helpers report to the owner, not directly to the user. - `agent.focus` changes visibility only; it does not transfer ownership. - Review helpers should default to read-only missions. +- Use helpers when the work splits cleanly: implementation, review/findings, or one bounded investigation. +- Prefer staying single-agent for tiny one-file edits or narrow wording changes where helper overhead would dominate. +- Prefer one bounded helper mission at a time such as "one finding or done", then follow up only if needed. +- If a helper reports late after timing out, treat the report as real work product first; do not assume the delivery path is fully broken. ## Helper reporting discipline (recommended) @@ -40,6 +44,10 @@ Paste this into your project `AGENTS.md` and customize. - After the first report succeeds, inspect the owner-provided target files before optional `/skills`, slash commands, or unrelated repo exploration unless blocked without them. - After the first report succeeds, continue the work and later report `done` or `review_findings`. - Once you have the bounded answer for the current pass, send the final `done` or `review_findings` report before extra prompts, `/skills`, or follow-up exploration. +- If this is a narrow review or investigation pass, return the first qualifying finding immediately instead of continuing to hunt for more. +- If `max_findings` is `1` or the completion criteria say "one finding or done", stop after the first qualifying result and report it immediately. +- If no qualifying finding appears within the scoped pass or timebox, send `done` with a concise no-findings summary instead of lingering silently. +- After your final `done` or `review_findings` report, stop and wait for the owner instead of continuing exploration on your own. - If the owner gave `target_paths`, `completion_criteria`, `timebox_minutes`, or `max_findings`, treat them as active mission constraints. - If the owner gave `target_paths`, read those exact stream-root/source-repo paths first instead of hunting for mirrored copies inside your helper worktree. diff --git a/doc/examples/AGENT_RULES.md b/doc/examples/AGENT_RULES.md index ad1b265..213374a 100644 --- a/doc/examples/AGENT_RULES.md +++ b/doc/examples/AGENT_RULES.md @@ -120,6 +120,11 @@ This preserves freshness even for similar text. - Helpers report to the owner, not to the user. Only the current user-facing owner asks the user for input or approval. - `agent.focus` changes visibility only; it does not transfer ownership. - Review helpers should default to read-only missions unless the owner explicitly chooses otherwise. +- Prefer spinning up a reviewer helper when the owner expects non-trivial code edits, broad config/docs changes, or a risky cross-cutting patch. +- Prefer spinning up an investigation helper when one bounded question can be answered independently while the owner continues another path. +- Prefer staying single-agent when the task is a tiny one-file edit, a narrowly scoped wording change, or anything where mission overhead would exceed the likely parallelism gain. +- Prefer one bounded helper mission at a time over a broad "review everything" request. Ask helpers for one finding or done, then follow up only if needed. +- If a helper acknowledges late (`acked_late`) after a timeout, treat that as evidence the mission eventually reached the helper. Review or resolve the report before concluding the delivery path is broken. ## 9. Helper reporting discipline @@ -130,6 +135,10 @@ This preserves freshness even for similar text. - After the first report succeeds, inspect the owner-provided target files before optional `/skills`, slash commands, or unrelated repo exploration unless you are blocked without them. - After the first report succeeds, continue the requested work. On completion, report `done` or `review_findings`. - Once you have a bounded answer that satisfies the current completion criteria, send the final `done` or `review_findings` report before any extra prompts, `/skills`, or follow-up exploration. +- If this is a narrow review or investigation pass, return the first qualifying finding immediately instead of continuing to hunt for more. +- If `max_findings` is `1` or the completion criteria say "one finding or done", stop after the first qualifying result and report it immediately. +- If no qualifying finding appears within the scoped pass or timebox, send `done` with a concise no-findings summary instead of lingering silently. +- After your final `done` or `review_findings` report, stop and wait for the owner instead of continuing exploration on your own. - If the owner provided `target_paths`, stay on those paths first. - Treat owner-provided `target_paths` as stream-root/source-repo anchored, even when they point outside your helper worktree. - If the owner provided `completion_criteria`, follow them exactly. diff --git a/doc/guides/operator-stack.md b/doc/guides/operator-stack.md index 35d48e2..8dd8d30 100644 --- a/doc/guides/operator-stack.md +++ b/doc/guides/operator-stack.md @@ -15,6 +15,13 @@ Use `./scripts/run-operator-once.sh --profile qwen3-realtime` when you want the Use `./scripts/run-operator-stack.sh` directly only when you intentionally want to manage tmux pane targeting and startup wiring yourself. +Profile shorthand for `run-operator-once.sh`: + +- `--profile default`: Kokoro TTS + batch ASR only +- `--profile realtime`: Kokoro TTS + Voxtral realtime ASR + Parakeet fallback +- `--profile qwen3`: Qwen3 TTS + batch ASR only +- `--profile qwen3-realtime`: Qwen3 TTS + Voxtral realtime ASR + Parakeet fallback + ### Quick start Minimal face-only path: @@ -323,6 +330,13 @@ Wrong pane is mirrored on mobile: `run-operator-once.sh` を使うと、これらのうち重要な接続先は自動で安全に埋まります。 +`run-operator-once.sh` の profile 対応: + +- `--profile default`: Kokoro TTS + batch ASR のみ +- `--profile realtime`: Kokoro TTS + Voxtral realtime ASR + Parakeet fallback +- `--profile qwen3`: Qwen3 TTS + batch ASR のみ +- `--profile qwen3-realtime`: Qwen3 TTS + Voxtral realtime ASR + Parakeet fallback + ### ASR モード ASR は 2 系統あります。 diff --git a/face-app/dist/agent_assignment_api.js b/face-app/dist/agent_assignment_api.js index 5488f1d..788bd34 100644 --- a/face-app/dist/agent_assignment_api.js +++ b/face-app/dist/agent_assignment_api.js @@ -143,27 +143,34 @@ export function renderAssignmentPrompt(assignment) { const streamRoot = deriveStreamRoot(assignment.stream_id); const targetPaths = formatTargetPaths(assignment.target_paths); const targetPathDescription = describeTargetPaths(targetPaths, streamRoot); + const protocolLines = [ + `1. Before reading repo files, skills, or running broad exploration, call the agent.report MCP tool (shown in some clients as minimum_headroom.agent_report) with stream_id=${assignment.stream_id}, mission_id=${assignment.mission_id}, owner_agent_id=${assignment.owner_agent_id}, from_agent_id=${assignment.agent_id}, kind=progress, summary='Mission accepted'.`, + '2. Wait until that first report call succeeds.', + '3. If blocked or uncertain, send blocked or question to the owner instead of asking the user directly.', + '4. Inspect the target paths before optional skill lookup, slash commands, or unrelated repo exploration unless blocked without them.', + '5. Send done or review_findings as soon as the current completion criteria are satisfied.', + '6. If this is a narrow review or investigation pass, return the first qualifying finding immediately instead of hunting for more.', + '7. If no qualifying finding appears within the stated scope or timebox, send done with a short no-findings summary instead of lingering.', + '8. After your final done/review_findings report, stop and wait for the owner.' + ]; + const shapingLines = [ + `- Stream root: ${streamRoot ?? '(not available)'}.`, + `- Target paths are stream-root anchored: ${targetPathDescription}.`, + '- Read the exact target path under the stream root even if it sits outside your helper worktree.', + `- Timebox: ${assignment.timebox_minutes ?? '(not specified)'} minute(s).`, + `- Completion criteria: ${assignment.completion_criteria ?? '(not specified)'}.`, + `- Max findings this pass: ${assignment.max_findings ?? '(not specified)'}.`, + '- If max_findings is 1 or the completion criteria say "one finding or done", stop after the first qualifying result and report it immediately.', + '- If the scope is still ambiguous after the first report, send question instead of broad repo exploration.' + ]; const explicitPrompt = asNonEmptyString(assignment.prompt_text); if (explicitPrompt) { const lines = [ `Owner assignment for helper agent ${assignment.agent_id}.`, - 'Bootstrap protocol (follow in order):', - `1. Before reading repo files, skills, or running broad exploration, call the agent.report MCP tool (shown in some clients as minimum_headroom.agent_report) with stream_id=${assignment.stream_id}, mission_id=${assignment.mission_id}, owner_agent_id=${assignment.owner_agent_id}, from_agent_id=${assignment.agent_id}, kind=progress, summary='Mission accepted'.`, - '2. Wait until that first report call succeeds.', - '3. After the first report succeeds, use the minimum-headroom-ops skill if it is available and relevant.', - '4. If you cannot accept the mission as written, send blocked or question to the owner instead of asking the user directly.', - '5. Keep the first pass narrow. Do not broaden the scope silently.', - '6. When the work is complete, send done or review_findings back to the owner.', - '7. After the first report succeeds, inspect the target paths before optional skill lookup, slash commands, or unrelated repo exploration unless the mission is blocked without them.', - '8. As soon as you have a bounded answer that satisfies the completion criteria, send the final done/review_findings report before any further prompts, /skills, or extra exploration.', + 'Immediate protocol:', + ...protocolLines, 'Execution shaping:', - `- Stream root for this mission: ${streamRoot ?? '(not available)'}.`, - `- If target_paths are given, treat them as stream-root anchored paths first: ${targetPathDescription}.`, - '- If a target path is outside your helper worktree but still under the stream root, inspect it there directly instead of broad repo exploration.', - `- If a timebox is given, stop and report by then: ${assignment.timebox_minutes ?? '(not specified)'} minute(s).`, - `- If completion criteria are given, follow them exactly: ${assignment.completion_criteria ?? '(not specified)'}.`, - `- If max_findings is given, return no more than that many findings on this pass: ${assignment.max_findings ?? '(not specified)'}.`, - '- If the scope is still ambiguous after the first report, send question instead of broad repo exploration.', + ...shapingLines, 'Mission body:', explicitPrompt ]; @@ -171,15 +178,8 @@ export function renderAssignmentPrompt(assignment) { } const lines = [ `Owner assignment for helper agent ${assignment.agent_id}.`, - 'Bootstrap protocol (follow in order):', - `1. Before reading repo files, skills, or running broad exploration, call the agent.report MCP tool (shown in some clients as minimum_headroom.agent_report) with stream_id=${assignment.stream_id}, mission_id=${assignment.mission_id}, owner_agent_id=${assignment.owner_agent_id}, from_agent_id=${assignment.agent_id}, kind=progress, summary='Mission accepted'.`, - '2. Wait until that first report call succeeds.', - '3. After the first report succeeds, use the minimum-headroom-ops skill if it is available and relevant.', - '4. If blocked or uncertain, report blocked or question to the owner instead of asking the user directly.', - '5. Keep the first pass narrow. Do not broaden the scope silently.', - '6. When the work is complete, send done or review_findings back to the owner.', - '7. After the first report succeeds, inspect the target paths before optional skill lookup, slash commands, or unrelated repo exploration unless the mission is blocked without them.', - '8. As soon as you have a bounded answer that satisfies the completion criteria, send the final done/review_findings report before any further prompts, /skills, or extra exploration.' + 'Immediate protocol:', + ...protocolLines ]; if (streamRoot) { lines.push(`Stream root: ${streamRoot}`); @@ -215,10 +215,10 @@ export function renderAssignmentPrompt(assignment) { lines.push('Scoping rules:'); lines.push('- Start with the minimum files or commands needed to answer the goal.'); lines.push('- If target paths are given, do not roam outside them without explaining why in your next report.'); - lines.push('- When a target path lives outside your helper worktree but under the stream root, inspect that exact path instead of guessing a mirrored location.'); - lines.push('- Read the target paths before optional /skills or slash-command detours unless you are blocked without them.'); lines.push('- Prefer returning one concrete result quickly unless the owner explicitly asked for a broader sweep.'); - lines.push('- Once you have the bounded result for this pass, report it immediately before any extra prompts or follow-up exploration.'); + lines.push('- If max_findings is 1 or the completion criteria say "one finding or done", stop after the first qualifying result and report it immediately.'); + lines.push('- If no qualifying finding appears within the scoped pass, send done with a concise no-findings summary instead of waiting silently.'); + lines.push('- After your final done/review_findings report, stop and wait for the owner instead of continuing exploration on your own.'); lines.push('- If the mission is ambiguous after the first report, send question instead of exploring broadly.'); lines.push('Begin now.'); return lines.join('\n'); diff --git a/face-app/dist/agent_assignment_state.js b/face-app/dist/agent_assignment_state.js index 2f4d067..7bf5b16 100644 --- a/face-app/dist/agent_assignment_state.js +++ b/face-app/dist/agent_assignment_state.js @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; const SCHEMA_VERSION = 1; -const DELIVERY_STATES = new Set(['pending', 'sent_to_tmux', 'acked', 'failed', 'timeout']); +const DELIVERY_STATES = new Set(['pending', 'sent_to_tmux', 'acked', 'acked_late', 'failed', 'timeout']); function toLogger(log) { if (!log) { @@ -219,6 +219,7 @@ function buildSummary(assignments) { pending: 0, sent_to_tmux: 0, acked: 0, + acked_late: 0, failed: 0, timeout: 0 }, @@ -232,6 +233,7 @@ function buildSummary(assignments) { pending: 0, sent_to_tmux: 0, acked: 0, + acked_late: 0, failed: 0, timeout: 0 }; @@ -548,7 +550,7 @@ export function createAgentAssignmentStateStore(options = {}) { assignment.last_report_kind = kind; assignment.last_report_at = ts; assignment.last_error = null; - assignment.delivery_state = 'acked'; + assignment.delivery_state = assignment.delivery_state === 'timeout' ? 'acked_late' : 'acked'; assignment.acked_at = ts; assignment.ack_deadline_at = 0; assignment.updated_at = ts; diff --git a/face-app/dist/agent_lifecycle.js b/face-app/dist/agent_lifecycle.js index 92e8b46..ff33f00 100644 --- a/face-app/dist/agent_lifecycle.js +++ b/face-app/dist/agent_lifecycle.js @@ -665,60 +665,130 @@ export function createAgentLifecycleRuntime(options = {}) { }; } + function buildBufferedTailNeedles(text) { + const lines = String(text ?? '') + .split('\n') + .map((line) => stripAnsi(String(line)).trim()) + .filter((line) => line !== ''); + const exactNeedles = []; + const markerNeedles = []; + const seenExact = new Set(); + const seenMarkers = new Set(); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const candidate = lines[index]; + if (seenExact.has(candidate)) { + continue; + } + seenExact.add(candidate); + exactNeedles.push(candidate); + if (exactNeedles.length >= 3) { + break; + } + } + for (let index = lines.length - 1; index >= Math.max(0, lines.length - 12); index -= 1) { + const candidate = lines[index]; + let marker = null; + const colonIndex = candidate.indexOf(':'); + if (colonIndex >= 6) { + marker = candidate.slice(0, colonIndex + 1).trim(); + } else if (/^\d+\./.test(candidate) || candidate.startsWith('- ')) { + marker = candidate.slice(0, Math.min(candidate.length, 24)).trimEnd(); + } + if (!marker || marker.length < 8 || seenMarkers.has(marker)) { + continue; + } + seenMarkers.add(marker); + markerNeedles.push(marker); + } + return [...exactNeedles, ...markerNeedles]; + } + + function paneContainsBufferedTail(lines, needles) { + if (!Array.isArray(lines) || lines.length === 0 || !Array.isArray(needles) || needles.length === 0) { + return false; + } + const normalizedLines = lines + .map((line) => stripAnsi(String(line)).trimEnd()) + .filter((line) => line.trim() !== ''); + if (normalizedLines.length === 0) { + return false; + } + const trailingWindow = normalizedLines.slice(-Math.max(8, needles.length * 4)); + return needles.some((needle) => trailingWindow.some((line) => line.includes(needle))); + } + async function maybeRescueBufferedSubmit(paneId, text, input = {}) { const enabled = normalizeBoolean(input.rescue_submit_if_buffered, text.includes('\n')); if (!enabled) { return { enabled: false, attempted: false, + attempt_count: 0, rescued: false, matched_line: null, + matched_lines: [], + buffered_still_visible: false, wait_ms: 0 }; } - const tailLine = text - .split('\n') - .map((line) => stripAnsi(String(line)).trim()) - .filter((line) => line !== '') - .at(-1); + const tailLines = buildBufferedTailNeedles(text); + const tailLine = tailLines[0] ?? null; if (!tailLine) { return { enabled: true, attempted: false, + attempt_count: 0, rescued: false, matched_line: null, + matched_lines: [], + buffered_still_visible: false, wait_ms: 0 }; } const waitMs = parseInteger(input.rescue_submit_delay_ms, 140, 20); - const lineCount = parseInteger(input.rescue_submit_capture_lines, helperInjectProbeCaptureLines, 4); + const pollMs = parseInteger(input.rescue_submit_poll_ms, 60, 20); + const timeoutMs = parseInteger(input.rescue_submit_timeout_ms, 520, waitMs); + const maxAttempts = parseInteger(input.rescue_submit_max_attempts, 2, 1); + const lineCount = parseInteger(input.rescue_submit_capture_lines, Math.max(helperInjectProbeCaptureLines, 16), 8); + const startedAt = now(); + const deadline = startedAt + timeoutMs; + let attemptCount = 0; + await delayMs(waitMs); - const lines = await capturePaneTail(paneId, lineCount); - const nonEmptyLines = lines - .map((line) => stripAnsi(String(line)).trimEnd()) - .filter((line) => line.trim() !== ''); - const trailingWindow = nonEmptyLines.slice(-3); - const appearsBuffered = trailingWindow.some((line) => line.includes(tailLine)); - if (!appearsBuffered) { - return { - enabled: true, - attempted: false, - rescued: false, - matched_line: tailLine, - wait_ms: waitMs - }; + while (now() <= deadline) { + const lines = await capturePaneTail(paneId, lineCount); + const appearsBuffered = paneContainsBufferedTail(lines, tailLines); + if (!appearsBuffered) { + return { + enabled: true, + attempted: attemptCount > 0, + attempt_count: attemptCount, + rescued: attemptCount > 0, + matched_line: tailLine, + matched_lines: tailLines, + buffered_still_visible: false, + wait_ms: Math.max(0, now() - startedAt) + }; + } + if (attemptCount < maxAttempts) { + await runTmux(['send-keys', '-t', paneId, 'C-m']); + attemptCount += 1; + } + await delayMs(pollMs); } - await runTmux(['send-keys', '-t', paneId, 'C-m']); return { enabled: true, - attempted: true, - rescued: true, + attempted: attemptCount > 0, + attempt_count: attemptCount, + rescued: false, matched_line: tailLine, - wait_ms: waitMs + matched_lines: tailLines, + buffered_still_visible: true, + wait_ms: Math.max(0, now() - startedAt) }; } diff --git a/face-app/public/agent_dashboard_state.js b/face-app/public/agent_dashboard_state.js index dafb1cd..e45151d 100644 --- a/face-app/public/agent_dashboard_state.js +++ b/face-app/public/agent_dashboard_state.js @@ -1,4 +1,6 @@ const KNOWN_AGENT_STATUSES = new Set(['active', 'missing']); +const KNOWN_ASSIGNMENT_DELIVERY_STATES = new Set(['pending', 'sent_to_tmux', 'acked', 'acked_late', 'failed', 'timeout']); +const FINAL_ASSIGNMENT_REPORT_KINDS = new Set(['done', 'review_findings']); function asNonEmptyString(value) { if (typeof value !== 'string') { @@ -24,6 +26,18 @@ export function normalizeAgentStatus(value) { return normalized; } +function normalizeAssignmentDeliveryState(value) { + const normalized = asNonEmptyString(value)?.toLowerCase() ?? 'pending'; + if (!KNOWN_ASSIGNMENT_DELIVERY_STATES.has(normalized)) { + return 'pending'; + } + return normalized; +} + +function normalizeAssignmentReportKind(value) { + return asNonEmptyString(value)?.toLowerCase() ?? null; +} + export function normalizeDashboardAgent(rawAgent = {}, index = 0) { return { id: asNonEmptyString(rawAgent?.id) ?? `agent-${index + 1}`, @@ -123,6 +137,95 @@ export function deriveAgentTileTone(agent, options = {}) { return normalizeAgentStatus(agent?.status) === 'active' ? 'active' : 'missing'; } +export function deriveAssignmentToneOptions(assignment) { + const deliveryState = normalizeAssignmentDeliveryState(assignment?.delivery_state); + const reportKind = normalizeAssignmentReportKind(assignment?.last_report_kind); + const finalReport = reportKind ? FINAL_ASSIGNMENT_REPORT_KINDS.has(reportKind) : false; + const activeMission = (deliveryState === 'acked' || deliveryState === 'acked_late') && !finalReport; + const needsAttention = + deliveryState === 'failed' || + deliveryState === 'timeout' || + reportKind === 'blocked' || + reportKind === 'question'; + return { + activeMission, + needsAttention, + suppressPromptIdle: deliveryState === 'sent_to_tmux' || activeMission + }; +} + +export function deriveAgentOperationalState(agent, options = {}) { + const agentStatus = normalizeAgentStatus(agent?.status); + if (agentStatus !== 'active') { + return 'missing'; + } + if (options.error === true) { + return 'error'; + } + + const assignment = options.assignment && typeof options.assignment === 'object' ? options.assignment : null; + const assignmentTone = deriveAssignmentToneOptions(assignment); + const reportKind = normalizeAssignmentReportKind(assignment?.last_report_kind); + const deliveryState = normalizeAssignmentDeliveryState(assignment?.delivery_state); + const inboxSummary = options.ownerInboxSummary && typeof options.ownerInboxSummary === 'object' + ? options.ownerInboxSummary + : null; + const informationalCount = asInteger(inboxSummary?.informational_count, 0, 0) ?? 0; + const nowMs = Number.isFinite(options.nowMs) ? Math.floor(options.nowMs) : Date.now(); + const assignmentReportAt = Number.isFinite(assignment?.last_report_at) ? Math.floor(assignment.last_report_at) : 0; + const lastActivityAt = Math.max( + Number.isFinite(options.lastActivityAt) ? Math.floor(options.lastActivityAt) : 0, + assignmentReportAt + ); + const recentActivityWindowMs = Number.isFinite(options.recentActivityWindowMs) + ? Math.max(1_500, Math.floor(options.recentActivityWindowMs)) + : 10_000; + const hasRecentActivity = lastActivityAt > 0 && nowMs - lastActivityAt < recentActivityWindowMs; + const finalReport = reportKind ? FINAL_ASSIGNMENT_REPORT_KINDS.has(reportKind) : false; + + if (options.needsAttention === true || assignmentTone.needsAttention) { + return 'needs_attention'; + } + if (deliveryState === 'sent_to_tmux') { + return 'awaiting_ack'; + } + if (finalReport && informationalCount > 0) { + return 'awaiting_review'; + } + if (options.speaking === true || hasRecentActivity) { + return 'working'; + } + if (assignmentTone.activeMission) { + return 'thinking'; + } + if (options.promptIdle === true) { + return 'idle'; + } + return 'working'; +} + +export function summarizeAgentOperationalState(state) { + switch (state) { + case 'awaiting_ack': + return 'awaiting_ack'; + case 'awaiting_review': + return 'awaiting_review'; + case 'thinking': + return 'thinking'; + case 'needs_attention': + return 'attention'; + case 'error': + return 'error'; + case 'missing': + return 'missing'; + case 'idle': + return 'idle'; + case 'working': + default: + return 'working'; + } +} + export function deriveOwnerInboxToneOptions(summary) { const blockingCount = asInteger(summary?.blocking_count, 0, 0) ?? 0; const errorCount = asInteger(summary?.error_count, 0, 0) ?? 0; @@ -147,7 +250,7 @@ export function summarizeOwnerInboxSummary(summary) { return null; } -export function summarizeAgentTileMessage(agent, transientMessage = null, ownerInboxMessage = null) { +export function summarizeAgentTileMessage(agent, transientMessage = null, ownerInboxMessage = null, operationalState = null) { const transient = asNonEmptyString(transientMessage); if (transient) { return transient; @@ -160,6 +263,22 @@ export function summarizeAgentTileMessage(agent, transientMessage = null, ownerI if (persisted) { return persisted; } + switch (operationalState) { + case 'awaiting_ack': + return 'awaiting first report'; + case 'awaiting_review': + return 'waiting for owner review'; + case 'thinking': + return 'quiet, mission in progress'; + case 'needs_attention': + return 'needs operator attention'; + case 'error': + return 'error'; + case 'idle': + return 'ready for next task'; + default: + break; + } switch (normalizeAgentStatus(agent?.status)) { case 'active': return 'working'; diff --git a/face-app/public/app.js b/face-app/public/app.js index 0bd0102..55a23b9 100644 --- a/face-app/public/app.js +++ b/face-app/public/app.js @@ -23,6 +23,8 @@ import { shouldStopBrowserAudioChannel } from './browser_audio_policy.js'; import { + deriveAgentOperationalState, + deriveAssignmentToneOptions, deriveAgentTileTone, deriveDashboardMode, deriveOwnerInboxToneOptions, @@ -31,6 +33,7 @@ import { shouldRefreshAgentActivityFromState, shouldUseAgentQuietPromptIdle, sortDashboardAgents, + summarizeAgentOperationalState, summarizeAgentTileMessage, summarizeOwnerInboxSummary } from './agent_dashboard_state.js'; @@ -399,6 +402,23 @@ let ownerInboxViewState = { by_agent_id: {} } }; +let agentAssignmentViewState = { + loaded: false, + assignments: [], + latestByAgentId: {}, + summary: { + count: 0, + by_delivery_state: { + pending: 0, + sent_to_tmux: 0, + acked: 0, + acked_late: 0, + failed: 0, + timeout: 0 + }, + by_agent_id: {} + } +}; const agentTransientStateById = new Map(); const operatorEscRecoveryTracker = createTapBurstTrigger({ requiredCount: OPERATOR_ESC_RECOVERY_REQUIRED_TAPS, @@ -606,12 +626,25 @@ function updateOperatorCurrentAgentBar() { const currentSummary = isOperatorDashboardAgentId(current?.id ?? OPERATOR_DASHBOARD_AGENT_ID) ? getOwnerInboxOverallSummary() : getOwnerInboxAgentSummary(current?.id ?? ''); + const currentAssignment = isOperatorDashboardAgentId(current?.id ?? OPERATOR_DASHBOARD_AGENT_ID) + ? null + : getLatestAgentAssignment(current?.id ?? ''); const unresolvedCount = Number.isFinite(currentSummary?.unresolved_count) ? Math.max(0, Math.floor(currentSummary.unresolved_count)) : 0; const label = current?.label ?? current?.id ?? OPERATOR_DASHBOARD_AGENT_LABEL; - const status = current?.status ?? 'active'; - const message = - summarizeOwnerInboxSummary(currentSummary) ?? - (typeof current?.last_message === 'string' ? current.last_message : ''); + const operationalState = deriveAgentOperationalState(current, { + ...toneOptions, + nowMs, + lastActivityAt: resolveAgentQuietActivityAt(current, agentTransientStateById.get(current?.id ?? '') ?? null), + ownerInboxSummary: currentSummary, + assignment: currentAssignment + }); + const status = summarizeAgentOperationalState(operationalState); + const message = summarizeAgentTileMessage( + current, + null, + summarizeOwnerInboxSummary(currentSummary), + operationalState + ); const countText = formatDashboardVisibleCount(); const paneText = operatorMirrorPaneId ? ` · ${operatorMirrorPaneId}` : ''; operatorCurrentAgentLabelEl.textContent = label; @@ -792,6 +825,91 @@ function createEmptyOwnerInboxViewState() { }; } +function createEmptyAgentAssignmentViewState() { + return { + loaded: true, + assignments: [], + latestByAgentId: {}, + summary: { + count: 0, + by_delivery_state: { + pending: 0, + sent_to_tmux: 0, + acked: 0, + acked_late: 0, + failed: 0, + timeout: 0 + }, + by_agent_id: {} + } + }; +} + +function normalizeAssignmentAgentSummary(rawSummary = {}) { + return { + count: Number.isFinite(rawSummary?.count) ? Math.max(0, Math.floor(rawSummary.count)) : 0, + pending: Number.isFinite(rawSummary?.pending) ? Math.max(0, Math.floor(rawSummary.pending)) : 0, + sent_to_tmux: Number.isFinite(rawSummary?.sent_to_tmux) ? Math.max(0, Math.floor(rawSummary.sent_to_tmux)) : 0, + acked: Number.isFinite(rawSummary?.acked) ? Math.max(0, Math.floor(rawSummary.acked)) : 0, + acked_late: Number.isFinite(rawSummary?.acked_late) ? Math.max(0, Math.floor(rawSummary.acked_late)) : 0, + failed: Number.isFinite(rawSummary?.failed) ? Math.max(0, Math.floor(rawSummary.failed)) : 0, + timeout: Number.isFinite(rawSummary?.timeout) ? Math.max(0, Math.floor(rawSummary.timeout)) : 0 + }; +} + +function normalizeAssignmentRecord(rawAssignment = {}) { + return { + stream_id: typeof rawAssignment?.stream_id === 'string' ? rawAssignment.stream_id : null, + mission_id: typeof rawAssignment?.mission_id === 'string' ? rawAssignment.mission_id : null, + agent_id: typeof rawAssignment?.agent_id === 'string' ? rawAssignment.agent_id : null, + delivery_state: typeof rawAssignment?.delivery_state === 'string' ? rawAssignment.delivery_state : 'pending', + last_report_kind: typeof rawAssignment?.last_report_kind === 'string' ? rawAssignment.last_report_kind : null, + last_report_at: Number.isFinite(rawAssignment?.last_report_at) ? Math.max(0, Math.floor(rawAssignment.last_report_at)) : 0, + updated_at: Number.isFinite(rawAssignment?.updated_at) ? Math.max(0, Math.floor(rawAssignment.updated_at)) : 0 + }; +} + +function normalizeAgentAssignmentViewState(rawState) { + const empty = createEmptyAgentAssignmentViewState(); + if (!rawState || typeof rawState !== 'object') { + return empty; + } + const assignments = Array.isArray(rawState.assignments) ? rawState.assignments.map((item) => normalizeAssignmentRecord(item)) : []; + const latestByAgentId = {}; + for (const assignment of assignments) { + if (!assignment.agent_id) { + continue; + } + const current = latestByAgentId[assignment.agent_id]; + if (!current || assignment.updated_at > current.updated_at) { + latestByAgentId[assignment.agent_id] = assignment; + } + } + const rawSummary = rawState.summary && typeof rawState.summary === 'object' ? rawState.summary : {}; + const rawByAgentId = rawSummary.by_agent_id && typeof rawSummary.by_agent_id === 'object' ? rawSummary.by_agent_id : {}; + const byAgentId = {}; + for (const [agentId, summary] of Object.entries(rawByAgentId)) { + byAgentId[agentId] = normalizeAssignmentAgentSummary(summary); + } + return { + loaded: true, + assignments, + latestByAgentId, + summary: { + count: Number.isFinite(rawSummary?.count) ? Math.max(0, Math.floor(rawSummary.count)) : assignments.length, + by_delivery_state: { + pending: Number.isFinite(rawSummary?.by_delivery_state?.pending) ? Math.max(0, Math.floor(rawSummary.by_delivery_state.pending)) : 0, + sent_to_tmux: Number.isFinite(rawSummary?.by_delivery_state?.sent_to_tmux) ? Math.max(0, Math.floor(rawSummary.by_delivery_state.sent_to_tmux)) : 0, + acked: Number.isFinite(rawSummary?.by_delivery_state?.acked) ? Math.max(0, Math.floor(rawSummary.by_delivery_state.acked)) : 0, + acked_late: Number.isFinite(rawSummary?.by_delivery_state?.acked_late) ? Math.max(0, Math.floor(rawSummary.by_delivery_state.acked_late)) : 0, + failed: Number.isFinite(rawSummary?.by_delivery_state?.failed) ? Math.max(0, Math.floor(rawSummary.by_delivery_state.failed)) : 0, + timeout: Number.isFinite(rawSummary?.by_delivery_state?.timeout) ? Math.max(0, Math.floor(rawSummary.by_delivery_state.timeout)) : 0 + }, + by_agent_id: byAgentId + } + }; +} + function normalizeOwnerInboxAgentSummary(rawSummary = {}) { return { agent_id: typeof rawSummary?.agent_id === 'string' ? rawSummary.agent_id : null, @@ -845,6 +963,13 @@ function getOwnerInboxAgentSummary(agentId) { return ownerInboxViewState?.summary?.by_agent_id?.[agentId] ?? null; } +function getLatestAgentAssignment(agentId) { + if (!agentId) { + return null; + } + return agentAssignmentViewState?.latestByAgentId?.[agentId] ?? null; +} + function syncSelectedDashboardAgentToMirrorPane() { if (!operatorMirrorPaneId) { return; @@ -1260,6 +1385,7 @@ function resolveAgentTransientToneOptions(agentId, agent, nowMs = Date.now()) { const transientNeedsAttention = Boolean(transient && transient.needsAttentionUntil > nowMs); const explicitPromptIdle = Boolean(transient && transient.promptIdleUntil > nowMs); const transientError = Boolean(transient && transient.errorUntil > nowMs); + const assignmentTone = deriveAssignmentToneOptions(getLatestAgentAssignment(agentId)); const inboxSummary = isOperatorDashboardAgentId(agentId) ? getOwnerInboxOverallSummary() : getOwnerInboxAgentSummary(agentId); @@ -1268,7 +1394,7 @@ function resolveAgentTransientToneOptions(agentId, agent, nowMs = Date.now()) { agentId === agentDashboardState.selectedAgentId && operatorActivePrompt && (operatorActivePrompt.state === 'awaiting_input' || operatorActivePrompt.state === 'awaiting_approval'); - const needsAttention = transientNeedsAttention || promptNeedsAttention || inboxTone.needsAttention; + const needsAttention = transientNeedsAttention || promptNeedsAttention || inboxTone.needsAttention || assignmentTone.needsAttention; const error = transientError || inboxTone.error; const lastActivityAt = resolveAgentQuietActivityAt(agent, transient); const quietPromptIdle = shouldUseAgentQuietPromptIdle({ @@ -1284,7 +1410,7 @@ function resolveAgentTransientToneOptions(agentId, agent, nowMs = Date.now()) { return { speaking, needsAttention, - promptIdle: (explicitPromptIdle || quietPromptIdle) && !speaking && !needsAttention && !error, + promptIdle: (explicitPromptIdle || quietPromptIdle) && !assignmentTone.suppressPromptIdle && !speaking && !needsAttention && !error, error }; } @@ -1360,6 +1486,25 @@ async function readOwnerInboxState(streamId = null) { return normalizeOwnerInboxViewState(payload.state); } +async function readAgentAssignmentState(streamId = null) { + const query = new URLSearchParams(); + if (typeof streamId === 'string' && streamId.trim() !== '') { + query.set('stream_id', streamId.trim()); + } + const response = await fetch(`/api/agent-assignments/list?${query.toString()}`, { + method: 'GET', + cache: 'no-store' + }); + if (!response.ok) { + throw new Error(`agent assignment request failed (${response.status})`); + } + const payload = await response.json(); + if (!payload || payload.ok !== true || !payload.state || typeof payload.state !== 'object') { + throw new Error('agent assignment response is invalid'); + } + return normalizeAgentAssignmentViewState(payload.state); +} + function syncAgentActivityFromDashboardState(previousAgents, nextAgents, nowMs = Date.now()) { const previousById = new Map((Array.isArray(previousAgents) ? previousAgents : []).map((agent) => [agent.id, agent])); for (const agent of Array.isArray(nextAgents) ? nextAgents : []) { @@ -1632,7 +1777,15 @@ function renderAgentDashboard() { const transient = agentTransientStateById.get(agent.id) ?? null; const toneOptions = resolveAgentTransientToneOptions(agent.id, agent, nowMs); const transientMessage = transient && transient.messageExpiresAt > nowMs ? transient.message : null; - const ownerInboxMessage = summarizeOwnerInboxSummary(getOwnerInboxAgentSummary(agent.id)); + const ownerInboxSummary = getOwnerInboxAgentSummary(agent.id); + const ownerInboxMessage = summarizeOwnerInboxSummary(ownerInboxSummary); + const operationalState = deriveAgentOperationalState(agent, { + ...toneOptions, + nowMs, + lastActivityAt: resolveAgentQuietActivityAt(agent, transient), + ownerInboxSummary, + assignment: getLatestAgentAssignment(agent.id) + }); const runtime = getOrCreateAgentFaceRuntime(agent.id, nowMs); const tile = document.createElement('article'); tile.className = 'agent-tile'; @@ -1669,7 +1822,7 @@ function renderAgentDashboard() { idEl.textContent = agent.id; const statusEl = document.createElement('span'); statusEl.className = 'agent-tile-status'; - statusEl.textContent = agent.status; + statusEl.textContent = summarizeAgentOperationalState(operationalState); header.append(idEl, statusEl); const sessionEl = document.createElement('div'); @@ -1678,7 +1831,7 @@ function renderAgentDashboard() { const messageEl = document.createElement('p'); messageEl.className = 'agent-tile-message'; - messageEl.textContent = summarizeAgentTileMessage(agent, transientMessage, ownerInboxMessage); + messageEl.textContent = summarizeAgentTileMessage(agent, transientMessage, ownerInboxMessage, operationalState); const speechBubble = transient && transient.speechBubbleExpiresAt > nowMs ? transient.speechBubble : null; if (speechBubble) { @@ -1773,15 +1926,25 @@ function renderOperatorMobileAgentList() { for (const agent of agentDashboardState.agents) { const transient = agentTransientStateById.get(agent.id) ?? null; const nowMs = Date.now(); - const ownerInboxMessage = summarizeOwnerInboxSummary(getOwnerInboxAgentSummary(agent.id)); + const ownerInboxSummary = getOwnerInboxAgentSummary(agent.id); + const ownerInboxMessage = summarizeOwnerInboxSummary(ownerInboxSummary); + const toneOptions = resolveAgentTransientToneOptions(agent.id, agent, nowMs); + const operationalState = deriveAgentOperationalState(agent, { + ...toneOptions, + nowMs, + lastActivityAt: resolveAgentQuietActivityAt(agent, transient), + ownerInboxSummary, + assignment: getLatestAgentAssignment(agent.id) + }); const message = summarizeAgentTileMessage( agent, transient && transient.messageExpiresAt > nowMs ? transient.message : null, - ownerInboxMessage + ownerInboxMessage, + operationalState ); const item = document.createElement('article'); item.className = 'operator-agent-item'; - item.dataset.tone = deriveAgentTileTone(agent, resolveAgentTransientToneOptions(agent.id, agent, nowMs)); + item.dataset.tone = deriveAgentTileTone(agent, toneOptions); if (agent.id === agentDashboardState.selectedAgentId) { item.classList.add('is-selected'); item.style.borderColor = 'rgba(111, 243, 184, 0.65)'; @@ -1804,7 +1967,7 @@ function renderOperatorMobileAgentList() { idEl.textContent = agent.id; const statusEl = document.createElement('span'); statusEl.className = 'operator-agent-item-status'; - statusEl.textContent = agent.status; + statusEl.textContent = summarizeAgentOperationalState(operationalState); header.append(idEl, statusEl); const messageEl = document.createElement('p'); @@ -1837,13 +2000,18 @@ async function refreshAgentDashboardState(options = {}) { console.warn(`[face-app] owner inbox refresh failed: ${error.message}`); return null; }); - const nextOwnerInboxState = await ownerInboxPromise; + const assignmentPromise = readAgentAssignmentState(nextDashboardState.activeStreamId).catch((error) => { + console.warn(`[face-app] agent assignment refresh failed: ${error.message}`); + return null; + }); + const [nextOwnerInboxState, nextAssignmentState] = await Promise.all([ownerInboxPromise, assignmentPromise]); syncAgentActivityFromDashboardState(previousAgents, nextDashboardState.agents, Date.now()); agentDashboardState.agents = nextDashboardState.agents; agentDashboardState.activeStreamId = nextDashboardState.activeStreamId; agentDashboardState.activeTargetRepoRoot = nextDashboardState.activeTargetRepoRoot; agentDashboardState.hiddenAgentCount = nextDashboardState.hiddenAgentCount; ownerInboxViewState = nextOwnerInboxState ?? createEmptyOwnerInboxViewState(); + agentAssignmentViewState = nextAssignmentState ?? createEmptyAgentAssignmentViewState(); agentDashboardState.loaded = true; ensureSelectedDashboardAgent(); renderAgentDashboard(); diff --git a/package.json b/package.json index 74eed5e..9f6bf42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minimum-headroom", - "version": "1.11.0", + "version": "1.12.0", "private": true, "type": "module", "scripts": { diff --git a/test/face-app/agent_assignment_api.test.mjs b/test/face-app/agent_assignment_api.test.mjs index 04bc951..8c7f69c 100644 --- a/test/face-app/agent_assignment_api.test.mjs +++ b/test/face-app/agent_assignment_api.test.mjs @@ -86,20 +86,21 @@ test('renderAssignmentPrompt prepends helper bootstrap guidance for generated pr max_findings: 1 }); - assert.match(prompt, /Bootstrap protocol \(follow in order\):/); + assert.match(prompt, /Immediate protocol:/); assert.match(prompt, /Before reading repo files, skills, or running broad exploration/); assert.match(prompt, /kind=progress, summary='Mission accepted'/); - assert.match(prompt, /After the first report succeeds, use the minimum-headroom-ops skill/); - assert.match(prompt, /inspect the target paths before optional skill lookup, slash commands, or unrelated repo exploration/); - assert.match(prompt, /When the work is complete, send done or review_findings/); - assert.match(prompt, /send the final done\/review_findings report before any further prompts, \/skills, or extra exploration/); + assert.match(prompt, /[Ii]nspect the target paths before optional skill lookup, slash commands, or unrelated repo exploration/); + assert.match(prompt, /Send done or review_findings as soon as the current completion criteria are satisfied/); + assert.match(prompt, /return the first qualifying finding immediately instead of hunting for more/); + assert.match(prompt, /If no qualifying finding appears within the stated scope or timebox, send done with a short no-findings summary/); + assert.match(prompt, /If max_findings is 1 or the completion criteria say "one finding or done", stop after the first qualifying result/); + assert.match(prompt, /After your final done\/review_findings report, stop and wait for the owner/); assert.match(prompt, /Stream root: \/tmp\/target/); assert.match(prompt, /Target paths \(stream-root anchored\): \/tmp\/target\/README\.md, \/tmp\/target\/doc\/examples\/AGENT_RULES\.md/); assert.match(prompt, /Completion criteria: Return one finding or done\./); assert.match(prompt, /Timebox minutes: 3/); assert.match(prompt, /Max findings this pass: 1/); assert.match(prompt, /Prefer returning one concrete result quickly/); - assert.match(prompt, /outside your helper worktree but under the stream root/); assert.match(prompt, /Goal: Review the patch/); }); @@ -114,13 +115,15 @@ test('renderAssignmentPrompt wraps explicit prompt_text with helper bootstrap gu }); assert.match(prompt, /^Owner assignment for helper agent helper-b\./); - assert.match(prompt, /Bootstrap protocol \(follow in order\):/); - assert.match(prompt, /After the first report succeeds, use the minimum-headroom-ops skill/); - assert.match(prompt, /Keep the first pass narrow/); - assert.match(prompt, /inspect the target paths before optional skill lookup, slash commands, or unrelated repo exploration/); - assert.match(prompt, /send the final done\/review_findings report before any further prompts, \/skills, or extra exploration/); + assert.match(prompt, /Immediate protocol:/); + assert.match(prompt, /[Ii]nspect the target paths before optional skill lookup, slash commands, or unrelated repo exploration/); + assert.match(prompt, /Send done or review_findings as soon as the current completion criteria are satisfied/); + assert.match(prompt, /return the first qualifying finding immediately instead of hunting for more/); + assert.match(prompt, /send done with a short no-findings summary instead of lingering/); + assert.match(prompt, /After your final done\/review_findings report, stop and wait for the owner/); assert.match(prompt, /If the scope is still ambiguous after the first report, send question/); - assert.match(prompt, /Stream root for this mission: \/tmp\/target\./); + assert.match(prompt, /Stream root: \/tmp\/target\./); + assert.match(prompt, /Read the exact target path under the stream root even if it sits outside your helper worktree/); assert.match(prompt, /Mission body:/); assert.match(prompt, /Investigate the failure and patch the bug\./); }); @@ -206,7 +209,6 @@ test('agent assignment api handles assign, list, and inject flows', async () => assert.match(runtimeCalls[0]?.input?.text ?? '', /Stream root: \/tmp\/target/); assert.match(runtimeCalls[0]?.input?.text ?? '', /Target paths \(stream-root anchored\): \/tmp\/target\/face-app\/dist\/agent_assignment_api\.js/); assert.match(runtimeCalls[0]?.input?.text ?? '', /Timebox minutes: 5/); - assert.match(runtimeCalls[0]?.input?.text ?? '', /outside your helper worktree but under the stream root/); assert.equal(runtimeCalls[0]?.input?.probe_before_send, false); const probeInjectRequest = createRequest({ diff --git a/test/face-app/agent_assignment_state.test.mjs b/test/face-app/agent_assignment_state.test.mjs index 2a346d5..edd52b4 100644 --- a/test/face-app/agent_assignment_state.test.mjs +++ b/test/face-app/agent_assignment_state.test.mjs @@ -175,3 +175,58 @@ test('agent assignment store lazily times out unacknowledged deliveries', () => cleanup(rootDir); }); + +test('agent assignment store promotes late reports after timeout to acked_late', () => { + const { rootDir, statePath } = createTempStatePath('mh-agent-assignment-late-ack-'); + let tick = 1_700_450_000_000; + const store = createAgentAssignmentStateStore({ + statePath, + now: () => tick, + log: quietLog + }); + store.load(); + + const created = store.upsertAssignment({ + stream_id: 'repo:/tmp/target', + mission_id: 'mission-late-ack', + owner_agent_id: '__operator__', + agent_id: 'helper-late-ack', + goal: 'Return a late acknowledgment' + }); + store.markDeliverySent({ + stream_id: 'repo:/tmp/target', + mission_id: 'mission-late-ack', + agent_id: 'helper-late-ack', + ack_timeout_ms: 1000 + }); + + tick += 1500; + const timedOut = store.getAssignment({ + stream_id: 'repo:/tmp/target', + mission_id: 'mission-late-ack' + }); + assert.equal(timedOut?.delivery_state, 'timeout'); + + tick += 250; + const acked = store.noteReport({ + stream_id: 'repo:/tmp/target', + mission_id: 'mission-late-ack', + from_agent_id: 'helper-late-ack', + kind: 'progress', + report_id: 'rpt-late-ack', + accepted_at: tick + }); + assert.equal(created.assignment.delivery_state, 'pending'); + assert.equal(acked.noop, false); + assert.equal(acked.assignment?.delivery_state, 'acked_late'); + assert.equal(acked.assignment?.last_report_id, 'rpt-late-ack'); + + const view = store.getAssignmentsView({ + stream_id: 'repo:/tmp/target' + }); + assert.equal(view.summary.by_delivery_state.acked_late, 1); + assert.equal(view.summary.by_delivery_state.timeout, 0); + assert.equal(view.summary.by_agent_id['helper-late-ack']?.acked_late, 1); + + cleanup(rootDir); +}); diff --git a/test/face-app/agent_dashboard_state.test.mjs b/test/face-app/agent_dashboard_state.test.mjs index d4b2a44..0a5d70f 100644 --- a/test/face-app/agent_dashboard_state.test.mjs +++ b/test/face-app/agent_dashboard_state.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + deriveAgentOperationalState, + deriveAssignmentToneOptions, deriveAgentTileTone, deriveDashboardMode, normalizeAgentStatus, @@ -9,6 +11,7 @@ import { shouldRefreshAgentActivityFromState, shouldUseAgentQuietPromptIdle, sortDashboardAgents, + summarizeAgentOperationalState, summarizeAgentTileMessage } from '../../face-app/public/agent_dashboard_state.js'; @@ -181,3 +184,104 @@ test('summarizeAgentTileMessage prefers transient then persisted text', () => { assert.equal(summarizeAgentTileMessage(agent, null), 'persisted'); assert.equal(summarizeAgentTileMessage(normalizeDashboardAgent({ status: 'missing' })), 'missing'); }); + +test('deriveAssignmentToneOptions suppresses prompt idle for active missions and flags blocked delivery', () => { + assert.deepEqual( + deriveAssignmentToneOptions({ + delivery_state: 'acked', + last_report_kind: 'progress' + }), + { + activeMission: true, + needsAttention: false, + suppressPromptIdle: true + } + ); + assert.deepEqual( + deriveAssignmentToneOptions({ + delivery_state: 'timeout', + last_report_kind: 'progress' + }), + { + activeMission: false, + needsAttention: true, + suppressPromptIdle: false + } + ); +}); + +test('deriveAgentOperationalState distinguishes awaiting ack, thinking, review wait, and idle', () => { + const agent = normalizeDashboardAgent({ id: 'helper-a', status: 'active', updated_at: 1_000 }); + + assert.equal( + deriveAgentOperationalState(agent, { + assignment: { + delivery_state: 'sent_to_tmux' + } + }), + 'awaiting_ack' + ); + + assert.equal( + deriveAgentOperationalState(agent, { + nowMs: 30_000, + lastActivityAt: 24_000, + promptIdle: false, + assignment: { + delivery_state: 'acked', + last_report_kind: 'progress', + last_report_at: 24_000 + } + }), + 'working' + ); + + assert.equal( + deriveAgentOperationalState(agent, { + nowMs: 30_000, + lastActivityAt: 10_000, + promptIdle: false, + recentActivityWindowMs: 5_000, + assignment: { + delivery_state: 'acked', + last_report_kind: 'progress', + last_report_at: 10_000 + } + }), + 'thinking' + ); + + assert.equal( + deriveAgentOperationalState(agent, { + promptIdle: true, + ownerInboxSummary: { + informational_count: 1 + }, + assignment: { + delivery_state: 'acked', + last_report_kind: 'review_findings', + last_report_at: 10_000 + } + }), + 'awaiting_review' + ); + + assert.equal( + deriveAgentOperationalState(agent, { + promptIdle: true + }), + 'idle' + ); +}); + +test('summaries expose the derived operational state text', () => { + assert.equal(summarizeAgentOperationalState('thinking'), 'thinking'); + assert.equal( + summarizeAgentTileMessage(normalizeDashboardAgent({ status: 'active' }), null, null, 'thinking'), + 'quiet, mission in progress' + ); + assert.equal( + summarizeAgentTileMessage(normalizeDashboardAgent({ status: 'active' }), null, null, 'awaiting_ack'), + 'awaiting first report' + ); +}); diff --git a/test/face-app/agent_lifecycle.test.mjs b/test/face-app/agent_lifecycle.test.mjs index f379550..a806320 100644 --- a/test/face-app/agent_lifecycle.test.mjs +++ b/test/face-app/agent_lifecycle.test.mjs @@ -593,8 +593,11 @@ test('agent lifecycle runtime can rescue buffered multiline submit with one extr assert.equal(result.ok, true); assert.equal(result.injection.rescue_submit.enabled, true); assert.equal(result.injection.rescue_submit.attempted, true); + assert.equal(result.injection.rescue_submit.attempt_count, 1); assert.equal(result.injection.rescue_submit.rescued, true); assert.equal(result.injection.rescue_submit.matched_line, 'Line two'); + assert.deepEqual(result.injection.rescue_submit.matched_lines, ['Line two', 'Line one']); + assert.equal(result.injection.rescue_submit.buffered_still_visible, false); assert.equal(enterCount, 2); assert.equal(lineBuffer, ''); assert.equal( @@ -612,6 +615,192 @@ test('agent lifecycle runtime can rescue buffered multiline submit with one extr cleanup(repoRoot); }); +test('agent lifecycle runtime waits for buffered multiline submit to disappear after rescue enter', async () => { + let lineBuffer = ''; + let enterCount = 0; + let captureCount = 0; + const { repoRoot, runtime } = createRuntimeHarness({ + commandRunner: async (command, args) => { + if (command === 'tmux' && args[0] === 'display-message') { + return { stdout: `${args[3]}\n`, stderr: '', code: 0 }; + } + if (command === 'tmux' && args[0] === 'capture-pane') { + captureCount += 1; + if (enterCount >= 2 && captureCount >= 3) { + lineBuffer = ''; + } + return { stdout: lineBuffer === '' ? '' : `${lineBuffer}\n`, stderr: '', code: 0 }; + } + if (command === 'tmux' && args[0] === 'send-keys' && args[3] === '-l') { + lineBuffer += args[5] ?? ''; + return { stdout: '', stderr: '', code: 0 }; + } + if (command === 'tmux' && args[0] === 'send-keys' && args[3] === 'C-m') { + enterCount += 1; + return { stdout: '', stderr: '', code: 0 }; + } + return { stdout: '', stderr: '', code: 0 }; + } + }); + + await runtime.addAgent({ + id: 'agent-rescue-submit-poll', + create_worktree: false, + create_tmux: false, + pane_id: '%97', + source_repo_path: repoRoot + }); + + const result = await runtime.injectAgent('agent-rescue-submit-poll', { + text: 'Line one\nLine two', + submit: true, + reinforce_submit: false, + rescue_submit_if_buffered: true, + rescue_submit_delay_ms: 20, + rescue_submit_poll_ms: 20, + rescue_submit_timeout_ms: 140 + }); + + assert.equal(result.ok, true); + assert.equal(result.injection.rescue_submit.enabled, true); + assert.equal(result.injection.rescue_submit.attempted, true); + assert.equal(result.injection.rescue_submit.attempt_count, 1); + assert.equal(result.injection.rescue_submit.rescued, true); + assert.equal(result.injection.rescue_submit.buffered_still_visible, false); + assert.equal(enterCount, 2); + assert.equal(captureCount >= 3, true); + assert.equal(lineBuffer, ''); + + cleanup(repoRoot); +}); + +test('agent lifecycle runtime detects buffered assignment from structured markers when tail lines wrap', async () => { + let enterCount = 0; + let rescued = false; + const bufferedSnapshot = [ + 'Completion criteria: Return one finding or done if none.', + 'Review policy: read_only', + 'Timebox minutes: 2', + 'Max findings this pass: 1', + '- After your f' + ].join('\n'); + const { repoRoot, runtime } = createRuntimeHarness({ + commandRunner: async (command, args) => { + if (command === 'tmux' && args[0] === 'display-message') { + return { stdout: `${args[3]}\n`, stderr: '', code: 0 }; + } + if (command === 'tmux' && args[0] === 'capture-pane') { + return { stdout: rescued ? '' : `${bufferedSnapshot}\n`, stderr: '', code: 0 }; + } + if (command === 'tmux' && args[0] === 'send-keys' && args[3] === 'C-m') { + enterCount += 1; + if (enterCount >= 2) { + rescued = true; + } + return { stdout: '', stderr: '', code: 0 }; + } + return { stdout: '', stderr: '', code: 0 }; + } + }); + + await runtime.addAgent({ + id: 'agent-rescue-structured-markers', + create_worktree: false, + create_tmux: false, + pane_id: '%98', + source_repo_path: repoRoot + }); + + const result = await runtime.injectAgent('agent-rescue-structured-markers', { + text: [ + 'Owner assignment for helper agent structured.', + 'Completion criteria: Return one finding or done if none.', + 'Review policy: read_only', + 'Timebox minutes: 2', + 'Max findings this pass: 1', + '- After your final done/review_findings report, stop and wait for the owner instead of continuing exploration on your own.', + 'Begin now.' + ].join('\n'), + submit: true, + reinforce_submit: false, + rescue_submit_if_buffered: true, + rescue_submit_delay_ms: 20, + rescue_submit_poll_ms: 20, + rescue_submit_timeout_ms: 140 + }); + + assert.equal(result.ok, true); + assert.equal(result.injection.rescue_submit.enabled, true); + assert.equal(result.injection.rescue_submit.attempted, true); + assert.equal(result.injection.rescue_submit.attempt_count, 1); + assert.equal(result.injection.rescue_submit.rescued, true); + assert.equal(result.injection.rescue_submit.buffered_still_visible, false); + assert.equal(result.injection.rescue_submit.matched_lines.includes('Completion criteria:'), true); + assert.equal(result.injection.rescue_submit.matched_lines.includes('Review policy:'), true); + assert.equal(enterCount, 2); + + cleanup(repoRoot); +}); + +test('agent lifecycle runtime can use a second rescue enter when buffered input still remains', async () => { + let lineBuffer = ''; + let enterCount = 0; + let captureCount = 0; + const { repoRoot, runtime } = createRuntimeHarness({ + commandRunner: async (command, args) => { + if (command === 'tmux' && args[0] === 'display-message') { + return { stdout: `${args[3]}\n`, stderr: '', code: 0 }; + } + if (command === 'tmux' && args[0] === 'capture-pane') { + captureCount += 1; + if (enterCount >= 3 && captureCount >= 4) { + lineBuffer = ''; + } + return { stdout: lineBuffer === '' ? '' : `${lineBuffer}\n`, stderr: '', code: 0 }; + } + if (command === 'tmux' && args[0] === 'send-keys' && args[3] === '-l') { + lineBuffer += args[5] ?? ''; + return { stdout: '', stderr: '', code: 0 }; + } + if (command === 'tmux' && args[0] === 'send-keys' && args[3] === 'C-m') { + enterCount += 1; + return { stdout: '', stderr: '', code: 0 }; + } + return { stdout: '', stderr: '', code: 0 }; + } + }); + + await runtime.addAgent({ + id: 'agent-rescue-submit-second-attempt', + create_worktree: false, + create_tmux: false, + pane_id: '%99', + source_repo_path: repoRoot + }); + + const result = await runtime.injectAgent('agent-rescue-submit-second-attempt', { + text: 'Line one\nLine two', + submit: true, + reinforce_submit: false, + rescue_submit_if_buffered: true, + rescue_submit_delay_ms: 20, + rescue_submit_poll_ms: 20, + rescue_submit_timeout_ms: 220, + rescue_submit_max_attempts: 2 + }); + + assert.equal(result.ok, true); + assert.equal(result.injection.rescue_submit.enabled, true); + assert.equal(result.injection.rescue_submit.attempted, true); + assert.equal(result.injection.rescue_submit.attempt_count, 2); + assert.equal(result.injection.rescue_submit.rescued, true); + assert.equal(result.injection.rescue_submit.buffered_still_visible, false); + assert.equal(enterCount, 3); + assert.equal(lineBuffer, ''); + + cleanup(repoRoot); +}); + test('agent lifecycle runtime fails reinstruction when probe never appears', async () => { const { repoRoot, runtime, commands, stateStore } = createRuntimeHarness({ commandRunner: async (command, args) => { diff --git a/tts-worker/pyproject.toml b/tts-worker/pyproject.toml index 0763186..8efc525 100644 --- a/tts-worker/pyproject.toml +++ b/tts-worker/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "minimum-headroom-tts-worker" -version = "1.11.0" +version = "1.12.0" description = "Minimum Headroom phase3 tts worker" readme = "README.md" requires-python = ">=3.12" diff --git a/tts-worker/uv.lock b/tts-worker/uv.lock index 60f7ecc..6d19960 100644 --- a/tts-worker/uv.lock +++ b/tts-worker/uv.lock @@ -331,7 +331,7 @@ wheels = [ [[package]] name = "minimum-headroom-tts-worker" -version = "1.11.0" +version = "1.12.0" source = { editable = "." } dependencies = [ { name = "fugashi" },