ouroboros/web/modules/task_control_menu.js
Ouroboros 3b36200880 Retire the bg-consciousness special cases from the UI and the gateway
A wake-up is an ordinary direct turn with its own task id, its own durable
result and its own card, so the pseudo task id "bg-consciousness" no longer
names anything live. Every special case built around it goes:

* chat_activity.js loses isBackgroundTaskId/shouldAlwaysShowTaskCard (the
  block appears under the same predicate as any other turn) and the
  "Background thinking" meta hardcode; the P1 origin label is the only one
  left. task_control_menu.js drops the id from REUSABLE_TASK_IDS, so a wake
  offers Stop and is scanned like any other unconfirmed card.
* model_wait.js loses syncBackground, the background owner, the paused phase
  and the "this wakeup cycle" wording; a wake's model wait is a direct turn's
  model wait. The consciousness role label stays (the model slot is honored).
* logs.js and log_events.js file and label a wake by `initiator` only; the
  end-of-cycle `consciousness_state` projector and the "thought"/"background"
  vocabulary are gone, as is the dead `source === 'consciousness'` own-loop
  branch.
* gateway/history.py no longer stamps the loop's last progress row "done" and
  no longer appends its live model-wait row. The retired loop wrote no
  task_result, so its legacy rows replay as any other row whose result is
  gone: the client's durable task-detail read settles the card as "Outcome
  unavailable", never as Done. The dead get_background_model_wait reader and
  the `background` parameter go with it.

The remaining stale mentions (a compacted context section, a context-health
error type, docstrings and comments) are corrected in place.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-16 06:13:31 +03:00

363 lines
15 KiB
JavaScript

// S3 (Q2/HQ1): the shared three-action task stop/hurry control.
//
// One dropdown of exactly three owner-decided actions — "Wrap up"
// (soft finalize-then-stop), "Hurry up" (typed task-local hurry control,
// NO chat message ever), "Stop now" (hard stop). Dismissing the
// menu continues the run (the dismiss affordance replaced the old separate
// "keep running" confirm). While a cancel intent is already pending, the only
// offered action is "Stop now" — the monotonic escalation of the
// SAME durable stop intent; "Hurry up" is refused then and never offered.
//
// Chat live cards and the Activity tab consume the SAME module (owner
// product-wide parity), so eligibility gates differ per surface but the
// actions, endpoint bindings, request-id retry, and refusals do not.
import { cancelTask, hurryTask, resumeTask } from './api_client.js';
import { showToast } from './toast.js';
export const ACTION_FINALIZE = 'finalize';
export const ACTION_HURRY = 'hurry';
export const ACTION_RESUME = 'resume';
export const ACTION_STOP_NOW = 'stop_now';
// Logical slots that may host multiple independent cycles (v6.82: shared so
// control eligibility and the chat card layer read the same truth). A
// consciousness wake-up is NOT one of them: it is an ordinary direct turn with
// its own task id and its own durable result.
export const REUSABLE_TASK_IDS = new Set(['active']);
/**
* v6.82 (P5): may this live card offer the stop/hurry control?
* Card shape alone cannot answer it — a subagent narration replayed without
* its lineage would mint a root-shaped card with a live Cancel. So eligibility
* requires the supervisor's host-attested `cancelable` progress-meta marker on
* top of the structural gates: a ROOT (non-subagent) card, not a reusable
* slot, not finished, not converted into a project chip. The marker is stamped
* from the ONE ownership seam the cancel endpoint itself consults — a pooled
* root's RUNNING row, or the in-process direct-chat turn (which has no queue
* row but is stopped cooperatively through the same owner mailbox) — so a
* card that shows the control is a task the endpoint will actually stop.
*/
export function cancelRunEligibility({
groupId = '', isSubagent = false, finished = false, cancelable = false, converted = false,
} = {}) {
return Boolean(cancelable)
&& !isSubagent
&& !finished
&& !converted
&& Boolean(String(groupId || '').trim())
&& !REUSABLE_TASK_IDS.has(String(groupId || ''));
}
// Frozen owner wording (Q2/HQ1) — exact strings, never localized/reworded here.
export const TASK_CONTROL_LABELS = Object.freeze({
[ACTION_FINALIZE]: 'Wrap up',
[ACTION_HURRY]: 'Hurry up',
[ACTION_RESUME]: 'Resume',
[ACTION_STOP_NOW]: 'Stop now',
});
/**
* The action set for the current card state (pure — node-testable).
* @param {{cancelPending?: boolean}} [state]
* @returns {string[]} ordered action ids
*/
export function taskControlActions({ cancelPending = false, budgetPaused = false } = {}) {
// A pending cancel refuses hurry (HQ1) and a second soft stop is a no-op:
// the single offered action is the hard escalation of the same intent.
if (cancelPending) return [ACTION_STOP_NOW];
// A budget-paused member is not running: nothing to wrap up or hurry.
// The host-attested pause fact gates the offer; the server re-validates
// (replay_unsafe and sibling checks answer 409 with the reason).
if (budgetPaused) return [ACTION_RESUME, ACTION_STOP_NOW];
return [ACTION_FINALIZE, ACTION_HURRY, ACTION_STOP_NOW];
}
export async function resumeTaskAction(taskId) {
const id = String(taskId || '');
if (!id || inFlight.has(id)) return;
inFlight.add(id);
try {
await resumeTask(id);
showToast('Resuming: the task returns to the queue.', 'info');
} catch (exc) {
// The server names the refusal (replay_unsafe / fence missing /
// not budget-paused): show it verbatim instead of a generic failure.
// Handled here, never rethrown: the menu callback is fire-and-forget.
showToast(`Resume refused: ${exc?.message || exc}`, 'error');
} finally {
inFlight.delete(id);
}
}
/**
* Map a stop action to the wire stop_policy (empty = not a stop action).
* @param {string} action
* @returns {string}
*/
export function stopPolicyFor(action) {
if (action === ACTION_FINALIZE) return 'finalize_then_cancel';
if (action === ACTION_STOP_NOW) return 'immediate';
return '';
}
// Stable per-task hurry request id (HQ1): a retry of the SAME click reuses the
// id so the endpoint acknowledges idempotently instead of minting a second
// typed control. Page-session scoped — a reload is a new owner intent.
const hurryRequestIds = new Map();
export function hurryRequestId(taskId) {
const id = String(taskId || '').trim();
if (!hurryRequestIds.has(id)) {
const uuid = (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function')
? globalThis.crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
hurryRequestIds.set(id, `hurry-${uuid}`);
}
return hurryRequestIds.get(id);
}
// One action in flight per task: the menu disables items while a request is
// awaiting, so a double click cannot race two controls.
const inFlight = new Set();
export function taskControlBusy(taskId) {
return inFlight.has(String(taskId || '').trim());
}
/**
* Submit the typed task-local hurry control (HQ1): body carries ONLY the
* stable request_id — no text field exists and no chat message is created
* anywhere on this path. Resolves with the acknowledgement (duplicate=true is
* the idempotent success shape); a typed refusal rejects.
* @param {string} taskId
* @returns {Promise<import('./api_types.js').TaskHurryResponse>}
*/
export async function requestHurry(taskId) {
const id = String(taskId || '').trim();
inFlight.add(id);
try {
return await hurryTask(id, hurryRequestId(id));
} finally {
inFlight.delete(id);
}
}
/**
* The COMPLETE "Hurry up" flow both surfaces share (HQ1): submit the typed
* control, acknowledge via LOCAL toast only — success, idempotent duplicate,
* or a visible typed refusal (e.g. a pending cancel). Never a chat message.
* @param {string} taskId
* @returns {Promise<boolean>} whether the control was accepted
*/
export async function hurryTaskAction(taskId) {
try {
const ack = await requestHurry(taskId);
showToast(ack?.duplicate
? 'Hurry up: already accepted for this task.'
: 'Hurry up: accepted — the task will speed up at the next boundary.', 'ok');
return true;
} catch (exc) {
showToast(`Hurry up: refused — ${exc?.message || exc}`, 'error');
return false;
}
}
/**
* Submit a stop action ({@link ACTION_FINALIZE} or {@link ACTION_STOP_NOW}).
* Both surfaces cancel the task AND its live subtree (v6.82 semantics kept).
* @param {string} taskId
* @param {string} action
* @returns {Promise<import('./api_types.js').TaskCancelResponse>}
*/
export async function requestStop(taskId, action) {
const id = String(taskId || '').trim();
inFlight.add(id);
try {
return await cancelTask(id, { cascade: true, stopPolicy: stopPolicyFor(action) });
} finally {
inFlight.delete(id);
}
}
// ---------------------------------------------------------------------------
// Dropdown DOM (shared by Chat live cards and the Activity tab)
// ---------------------------------------------------------------------------
let openMenu = null;
let openTrigger = null;
let stopMenuWatcher = null;
let focusReturnTimer = null;
const MENU_MARGIN = 8;
const MENU_GAP = 4;
function clamp(value, minimum, maximum) {
return Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
}
function positionTaskControlMenu(anchor, menu) {
if (!anchor?.isConnected || !menu?.isConnected) {
closeTaskControlMenu();
return false;
}
const anchorRect = anchor.getBoundingClientRect();
if (anchorRect.width <= 0 || anchorRect.height <= 0) {
closeTaskControlMenu();
return false;
}
const menuRect = menu.getBoundingClientRect();
// scrollHeight preserves the intrinsic item height if the viewport-level
// fallback max-height has already constrained the rendered border box.
const borderHeight = Math.max(0, menuRect.height - menu.clientHeight);
const naturalHeight = Math.max(menuRect.height, menu.scrollHeight + borderHeight);
const spaceAbove = Math.max(0, anchorRect.top - MENU_GAP - MENU_MARGIN);
const spaceBelow = Math.max(
0,
window.innerHeight - MENU_MARGIN - anchorRect.bottom - MENU_GAP,
);
const openUpwards = spaceBelow < naturalHeight && spaceAbove > spaceBelow;
const availableHeight = openUpwards ? spaceAbove : spaceBelow;
const effectiveHeight = Math.min(naturalHeight, availableHeight);
const top = openUpwards
? Math.max(MENU_MARGIN, anchorRect.top - MENU_GAP - effectiveHeight)
: anchorRect.bottom + MENU_GAP;
const left = clamp(
anchorRect.right - menuRect.width,
MENU_MARGIN,
window.innerWidth - menuRect.width - MENU_MARGIN,
);
menu.style.setProperty('--tcm-top', `${Math.round(top)}px`);
menu.style.setProperty('--tcm-left', `${Math.round(left)}px`);
menu.style.setProperty('--tcm-max-height', `${Math.floor(availableHeight)}px`);
return true;
}
function watchTaskControlMenu(anchor, menu) {
const isCurrentMenu = () => openMenu === menu && openTrigger === anchor;
const closeForViewportChange = (event) => {
// A height-clamped portal is its own scrollport. Scrolling it does not
// move the trigger, so keep it open; ancestor/page scroll still closes.
if (event.type === 'scroll' && event.target === menu) return;
if (isCurrentMenu()) closeTaskControlMenu();
};
document.addEventListener('scroll', closeForViewportChange, true);
window.addEventListener('resize', closeForViewportChange);
const observer = new IntersectionObserver((entries) => {
if (!isCurrentMenu()) return;
const entry = entries.find((candidate) => candidate.target === anchor);
if (!anchor.isConnected || (entry && !entry.isIntersecting)) {
closeTaskControlMenu();
}
}, { threshold: 0 });
observer.observe(anchor);
return () => {
observer.takeRecords();
observer.disconnect();
document.removeEventListener('scroll', closeForViewportChange, true);
window.removeEventListener('resize', closeForViewportChange);
};
}
function clearPendingFocusReturn() {
if (focusReturnTimer !== null) window.clearTimeout(focusReturnTimer);
focusReturnTimer = null;
}
function finishOutsidePointerFocusReturn(trigger) {
clearPendingFocusReturn();
focusReturnTimer = window.setTimeout(() => {
focusReturnTimer = null;
if (!openMenu && trigger?.isConnected) trigger.focus();
}, 0);
}
export function closeTaskControlMenu() {
clearPendingFocusReturn();
const menu = openMenu;
const trigger = openTrigger;
const restoreFocus = Boolean(menu?.contains(document.activeElement));
const stopWatching = stopMenuWatcher;
stopMenuWatcher = null;
stopWatching?.();
menu?.remove();
openMenu = null;
trigger?.setAttribute?.('aria-expanded', 'false');
openTrigger = null;
document.removeEventListener('pointerdown', onOutsidePointer, true);
document.removeEventListener('keydown', onMenuKeydown, true);
if (restoreFocus && trigger?.isConnected) trigger.focus();
}
function onOutsidePointer(event) {
if (!openMenu || openMenu.contains(event.target)) return;
const trigger = openTrigger;
const restoreFocus = openMenu.contains(document.activeElement);
closeTaskControlMenu();
// The pointerdown default focuses the clicked element after capture-phase
// listeners run. Restore once more in the next task without cancelling the
// outside element's click; a successor task menu cancels this stale timer.
if (restoreFocus && trigger?.isConnected) finishOutsidePointerFocusReturn(trigger);
}
function onMenuKeydown(event) {
// Dismiss = continue the run (Q2: the dismiss affordance replaced the old
// explicit "keep running" item).
if (event.key === 'Escape') {
event.preventDefault();
closeTaskControlMenu();
}
}
/**
* Open the three-action dropdown in a viewport-level portal anchored to `anchor`.
* Dismissing (outside click / Escape) continues the run. Selecting an item
* closes the menu and invokes `onAction(actionId)`.
* @param {HTMLElement} anchor trigger element
* @param {{cancelPending?: boolean, busy?: boolean, onAction: (action: string) => void}} opts
*/
export function openTaskControlMenu(anchor, { cancelPending = false, budgetPaused = false, busy = false, onAction } = {}) {
closeTaskControlMenu();
if (!anchor?.isConnected || !document.body) return null;
// A11y: the trigger owns a popup menu; expanded tracks the open state.
anchor.setAttribute('aria-haspopup', 'menu');
anchor.setAttribute('aria-expanded', 'true');
const menu = document.createElement('div');
menu.className = 'task-control-menu';
menu.setAttribute('role', 'menu');
for (const action of taskControlActions({ cancelPending, budgetPaused })) {
const item = document.createElement('button');
item.type = 'button';
item.className = `task-control-item${action === ACTION_STOP_NOW ? ' danger' : ''}`;
item.dataset.taskControl = action;
item.setAttribute('role', 'menuitem');
item.textContent = TASK_CONTROL_LABELS[action];
if (busy) item.disabled = true;
item.addEventListener('click', (event) => {
event.stopPropagation();
closeTaskControlMenu();
onAction?.(action);
});
menu.appendChild(item);
}
document.body.appendChild(menu);
openMenu = menu;
openTrigger = anchor;
stopMenuWatcher = watchTaskControlMenu(anchor, menu);
if (!positionTaskControlMenu(anchor, menu)) return null;
// A11y: keyboard users land on the first actionable item on open.
menu.querySelector('button:not(:disabled)')?.focus?.();
document.addEventListener('pointerdown', onOutsidePointer, true);
document.addEventListener('keydown', onMenuKeydown, true);
return menu;
}
// The trigger's constant label. The trigger always opens the dropdown; the
// pending-cancel state changes the OFFERED actions (escalation only), not the
// interaction shape — so an accidental click never hard-stops directly.
export const TASK_CONTROL_TRIGGER_LABEL = 'Stop…';