Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion asr-worker/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion asr-worker/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion doc/examples/AGENT_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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.
12 changes: 10 additions & 2 deletions face-app/dist/agent_assignment_state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {} };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 10 additions & 2 deletions face-app/dist/owner_inbox_state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {} };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "minimum-headroom",
"version": "1.13.3",
"version": "1.13.4",
"private": true,
"type": "module",
"scripts": {
Expand Down
48 changes: 48 additions & 0 deletions test/face-app/agent_assignment_state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
53 changes: 53 additions & 0 deletions test/face-app/owner_inbox_state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion tts-worker/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tts-worker/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading