From a0837284f7afbd3638374af6bad25884b8386cfc Mon Sep 17 00:00:00 2001 From: amariichi <68761912+amariichi@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:04:07 +0900 Subject: [PATCH] fix: normalize owner_agent_id to resolve stream owner mismatch and release v1.13.4 (#33) Normalize "operator" and "__operator__" to a single canonical ID in both owner_inbox_state and agent_assignment_state, preventing stream owner mismatch errors when MCP-driven agent.assign uses a different alias than persisted state. Adds dedicated normalization tests and syncs AGENT_RULES.md resilience guidance. Co-Authored-By: Claude Opus 4.6 --- asr-worker/pyproject.toml | 2 +- asr-worker/uv.lock | 2 +- doc/examples/AGENT_RULES.md | 7 ++- face-app/dist/agent_assignment_state.js | 12 ++++- face-app/dist/owner_inbox_state.js | 12 ++++- package.json | 2 +- test/face-app/agent_assignment_state.test.mjs | 48 +++++++++++++++++ test/face-app/owner_inbox_state.test.mjs | 53 +++++++++++++++++++ tts-worker/pyproject.toml | 2 +- tts-worker/uv.lock | 2 +- 10 files changed, 132 insertions(+), 10 deletions(-) diff --git a/asr-worker/pyproject.toml b/asr-worker/pyproject.toml index ab03059..028d814 100644 --- a/asr-worker/pyproject.toml +++ b/asr-worker/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "asr-worker" -version = "1.13.3" +version = "1.13.4" 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 b885964..da05e64 100644 --- a/asr-worker/uv.lock +++ b/asr-worker/uv.lock @@ -247,7 +247,7 @@ wheels = [ [[package]] name = "asr-worker" -version = "1.13.3" +version = "1.13.4" source = { editable = "." } dependencies = [ { name = "fastapi" }, diff --git a/doc/examples/AGENT_RULES.md b/doc/examples/AGENT_RULES.md index e653a50..29c4dc6 100644 --- a/doc/examples/AGENT_RULES.md +++ b/doc/examples/AGENT_RULES.md @@ -115,6 +115,7 @@ This preserves freshness even for similar text. - Treat `target_paths` as stream-root/source-repo anchored. They may point outside the helper worktree; helpers should inspect those exact paths under the stream root instead of guessing mirrored locations inside their own worktree. - For reinstruction to an already-running helper, prefer `agent.inject(..., probe_before_send=true)` when the helper may be sitting at a prompt or when input readiness is uncertain. The probe sends a short ASCII token, checks that it appears, erases it with matching backspaces, and only then sends the real text. - If a multiline mission still appears buffered in the helper input after submit, prefer `agent.inject(..., rescue_submit_if_buffered=true)` so the runtime can send one guarded extra `Enter` only when the buffered tail is still visibly present. +- After `agent.inject`, verify the helper can use MCP tools by checking whether a `progress` report arrives in `owner.inbox.list` within the ack deadline. If no `progress` arrives and `agent.assignment.list` shows `timeout`, the helper may be blocked by tool permission prompts rather than stalled on work. In that case, surface `needs_attention` and check the helper pane directly instead of firing a rescue. - After `agent.inject`, expect helper acknowledgment through `agent.report`. A matching `progress`, `blocked`, `question`, `done`, or `review_findings` report counts as acknowledgment. - If delivery reaches `failed` or `timeout`, retry injection at most once. If acknowledgment still does not arrive, surface that helper as `needs_attention`. - If a probe-based reinstruction fails, stop and surface `needs_attention` instead of looping repeated probe attempts. @@ -129,7 +130,10 @@ This preserves freshness even for similar text. - 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. - If a helper has acknowledged but still has no final `done` or `review_findings` after the scoped timebox or a long quiet window, wait through a short grace period first (about 10 seconds, or use `completion_rescue_ready_at` / `completion_rescue_wait_ms` from `agent.assignment.list` when available). -- After that grace window expires, prefer a bounded follow-up such as `agent.inject(..., followup_mode="completion_rescue", probe_before_send=true, rescue_submit_if_buffered=true)` instead of broad reinstruction. +- After that grace window expires, check `owner.inbox.list` for that helper first; if a `done` or `review_findings` report has already arrived since the mission was assigned, skip the rescue entirely. +- If `owner.inbox.list` shows zero reports (not even `progress`) for the helper since injection, treat the report channel as potentially broken. Check the helper pane directly for output instead of firing a rescue into a possibly permission-blocked helper. +- If the inbox shows at least a `progress` ack but no final report yet, prefer a bounded follow-up such as `agent.inject(..., followup_mode="completion_rescue", probe_before_send=true, rescue_submit_if_buffered=true)` instead of broad reinstruction. +- If two final reports from the same helper arrive close together after a rescue, resolve the earlier one with `owner.inbox.resolve` and treat the later one as the authoritative result. ## 9. Helper reporting discipline @@ -151,3 +155,4 @@ This preserves freshness even for similar text. - If scope is still broad or ambiguous after the first report, send `question` instead of broad repo exploration. - If the owner sends a follow-up reinstruction, apply the same discipline again: acknowledge or escalate promptly instead of drifting into unrelated exploration first. - If the owner sends a completion rescue follow-up, do not restart broad exploration. Send `done`, `review_findings`, `blocked`, or `question` immediately from the current scoped work. +- If `agent.report` calls fail due to tool permissions or MCP connectivity, continue the assigned work and leave your results visible in terminal output. The operator may check your pane directly as a fallback. diff --git a/face-app/dist/agent_assignment_state.js b/face-app/dist/agent_assignment_state.js index 6822f25..db9ed16 100644 --- a/face-app/dist/agent_assignment_state.js +++ b/face-app/dist/agent_assignment_state.js @@ -6,6 +6,14 @@ const SCHEMA_VERSION = 1; const DELIVERY_STATES = new Set(['pending', 'sent_to_tmux', 'acked', 'acked_late', 'failed', 'timeout']); const DEFAULT_COMPLETION_RESCUE_GRACE_MS = 10_000; +const CANONICAL_OPERATOR_ID = '__operator__'; +const OPERATOR_ALIASES = /^_*operator_*$/i; + +function normalizeOwnerAgentId(id) { + if (typeof id === 'string' && OPERATOR_ALIASES.test(id)) return CANONICAL_OPERATOR_ID; + return id; +} + function toLogger(log) { if (!log) { return { info: () => {}, warn: () => {}, error: () => {} }; @@ -377,7 +385,7 @@ export function createAgentAssignmentStateStore(options = {}) { refreshTimeouts(); const ts = nowMs(now); const streamId = asNonEmptyString(filters.stream_id); - const ownerAgentId = asNonEmptyString(filters.owner_agent_id); + const ownerAgentId = normalizeOwnerAgentId(asNonEmptyString(filters.owner_agent_id)); const agentId = asNonEmptyString(filters.agent_id); const missionId = asNonEmptyString(filters.mission_id); const listed = state.assignments @@ -416,7 +424,7 @@ export function createAgentAssignmentStateStore(options = {}) { ensureLoaded(); const streamId = asNonEmptyString(input.stream_id); const missionId = asNonEmptyString(input.mission_id); - const ownerAgentId = asNonEmptyString(input.owner_agent_id); + const ownerAgentId = normalizeOwnerAgentId(asNonEmptyString(input.owner_agent_id)); const agentId = asNonEmptyString(input.agent_id); const goal = asNonEmptyString(input.goal); if (!streamId || !missionId || !ownerAgentId || !agentId || !goal) { diff --git a/face-app/dist/owner_inbox_state.js b/face-app/dist/owner_inbox_state.js index 797d2f3..ca7941c 100644 --- a/face-app/dist/owner_inbox_state.js +++ b/face-app/dist/owner_inbox_state.js @@ -18,6 +18,14 @@ const REPORT_LIFECYCLE_STATES = new Set([ const TERMINAL_REPORT_STATES = new Set(['resolved', 'superseded', 'dismissed']); const RESOLVABLE_REPORT_STATES = new Set(['seen_by_owner', 'acted_on', 'resolved', 'dismissed']); +const CANONICAL_OPERATOR_ID = '__operator__'; +const OPERATOR_ALIASES = /^_*operator_*$/i; + +function normalizeOwnerAgentId(id) { + if (typeof id === 'string' && OPERATOR_ALIASES.test(id)) return CANONICAL_OPERATOR_ID; + return id; +} + function toLogger(log) { if (!log) { return { info: () => {}, warn: () => {}, error: () => {} }; @@ -559,7 +567,7 @@ export function createOwnerInboxStateStore(options = {}) { function listReports(filters = {}) { ensureLoaded(); - const ownerAgentId = asNonEmptyString(filters.owner_agent_id); + const ownerAgentId = normalizeOwnerAgentId(asNonEmptyString(filters.owner_agent_id)); const streamId = asNonEmptyString(filters.stream_id); const includeResolved = asBoolean(filters.include_resolved, false); const reports = state.reports @@ -595,7 +603,7 @@ export function createOwnerInboxStateStore(options = {}) { ensureLoaded(); const streamId = asNonEmptyString(input.stream_id); const missionId = asNonEmptyString(input.mission_id); - const ownerAgentId = asNonEmptyString(input.owner_agent_id); + const ownerAgentId = normalizeOwnerAgentId(asNonEmptyString(input.owner_agent_id)); const fromAgentId = asNonEmptyString(input.from_agent_id); const summary = asNonEmptyString(input.summary); if (!streamId || !missionId || !ownerAgentId || !fromAgentId || !summary) { diff --git a/package.json b/package.json index 7f5ef0a..1fc24ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minimum-headroom", - "version": "1.13.3", + "version": "1.13.4", "private": true, "type": "module", "scripts": { diff --git a/test/face-app/agent_assignment_state.test.mjs b/test/face-app/agent_assignment_state.test.mjs index fde24da..51d10fb 100644 --- a/test/face-app/agent_assignment_state.test.mjs +++ b/test/face-app/agent_assignment_state.test.mjs @@ -102,6 +102,54 @@ test('agent assignment store upserts missions and resets delivery state on updat cleanup(rootDir); }); +test('agent assignment store normalizes owner_agent_id so "operator" matches "__operator__"', () => { + const { rootDir, statePath } = createTempStatePath('mh-agent-assignment-normalize-'); + const store = createAgentAssignmentStateStore({ + statePath, + now: createClock(), + log: quietLog + }); + store.load(); + + // Create with canonical __operator__ + store.upsertAssignment({ + stream_id: 'repo:/tmp/target', + mission_id: 'mission-norm', + owner_agent_id: '__operator__', + agent_id: 'helper-norm', + goal: 'Test normalization' + }); + + // Upsert with bare "operator" — should update the same assignment + const updated = store.upsertAssignment({ + stream_id: 'repo:/tmp/target', + mission_id: 'mission-norm', + owner_agent_id: 'operator', + agent_id: 'helper-norm', + goal: 'Updated via alias' + }); + + assert.equal(updated.action, 'updated'); + assert.equal(updated.assignment.assignment_revision, 2); + assert.equal(updated.assignment.goal, 'Updated via alias'); + + // List with bare "operator" should find the assignment + const view = store.getAssignmentsView({ + stream_id: 'repo:/tmp/target', + owner_agent_id: 'operator' + }); + assert.equal(view.assignments.length, 1); + + // List with canonical __operator__ should also find it + const viewCanonical = store.getAssignmentsView({ + stream_id: 'repo:/tmp/target', + owner_agent_id: '__operator__' + }); + assert.equal(viewCanonical.assignments.length, 1); + + cleanup(rootDir); +}); + test('agent assignment store marks sent deliveries and acknowledges them through matching reports', () => { const { rootDir, statePath } = createTempStatePath('mh-agent-assignment-ack-'); const store = createAgentAssignmentStateStore({ diff --git a/test/face-app/owner_inbox_state.test.mjs b/test/face-app/owner_inbox_state.test.mjs index f2571a3..b3fe49c 100644 --- a/test/face-app/owner_inbox_state.test.mjs +++ b/test/face-app/owner_inbox_state.test.mjs @@ -143,6 +143,59 @@ test('owner inbox store supersedes earlier helper reports and resolves explicitl cleanup(rootDir); }); +test('owner inbox store normalizes owner_agent_id so "operator" matches "__operator__"', () => { + const { rootDir, statePath } = createTempStatePath('mh-owner-inbox-normalize-'); + const store = createOwnerInboxStateStore({ + statePath, + now: createClock(), + log: quietLog + }); + store.load(); + + // Submit with canonical __operator__ + store.submitReport({ + stream_id: 'operator-default', + mission_id: 'helper-norm', + owner_agent_id: '__operator__', + from_agent_id: 'helper-norm', + kind: 'progress', + summary: 'Started', + report_id: 'rpt-norm-1' + }); + + // Submit with bare "operator" — should land in the same stream + const aliased = store.submitReport({ + stream_id: 'operator-default', + mission_id: 'helper-norm', + owner_agent_id: 'operator', + from_agent_id: 'helper-norm', + kind: 'done', + summary: 'Finished', + report_id: 'rpt-norm-2' + }); + + assert.equal(aliased.transport_state, 'accepted'); + assert.equal(store.getState().reports.length, 2); + + // getInboxView with bare "operator" should find both reports + const view = store.getInboxView({ + stream_id: 'operator-default', + owner_agent_id: 'operator', + include_resolved: true + }); + assert.equal(view.reports.length, 2); + + // getInboxView with canonical __operator__ should also find both + const viewCanonical = store.getInboxView({ + stream_id: 'operator-default', + owner_agent_id: '__operator__', + include_resolved: true + }); + assert.equal(viewCanonical.reports.length, 2); + + cleanup(rootDir); +}); + test('owner inbox store rejects late reports for closed streams', () => { const { rootDir, statePath } = createTempStatePath('mh-owner-inbox-closed-'); const store = createOwnerInboxStateStore({ diff --git a/tts-worker/pyproject.toml b/tts-worker/pyproject.toml index 4a1201f..7d4c426 100644 --- a/tts-worker/pyproject.toml +++ b/tts-worker/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "minimum-headroom-tts-worker" -version = "1.13.3" +version = "1.13.4" 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 1e5fd1d..687c850 100644 --- a/tts-worker/uv.lock +++ b/tts-worker/uv.lock @@ -331,7 +331,7 @@ wheels = [ [[package]] name = "minimum-headroom-tts-worker" -version = "1.13.3" +version = "1.13.4" source = { editable = "." } dependencies = [ { name = "fugashi" },