openvibers/openvibe.actor Commits
Observe then auto: lanes the owner can promote (#399)
Changes
@@@ -12,17 +12,17 @@ An owner uses their `actor.agent.manage` credential:- `GET /api/v1/fabric/:agent` reads state and revision.- `POST /api/v1/fabric/:agent/commands/configure` stores `{sources, policy}`.--- Commands `start`, `decide`, `attach`, `stop`, `end`, `subscribe`, `unsubscribe`, `handBack`, `read`, `reply`, `schedule`, `cancelSchedule`, `tick`, `claim`, `settle`, `delivered` expose the engine ports. Unknown commands and foreign agents are refused. Source intake is not a public command.++- Commands `start`, `decide`, `attach`, `stop`, `end`, `subscribe`, `unsubscribe`, `handBack`, `read`, `reply`, `schedule`, `cancelSchedule`, `tick`, `claim`, `settle`, `delivered`, `bind`, `report`, `promote`, `demote` expose the engine ports. Unknown commands and foreign agents are refused. Source intake is not a public command.Source configuration maps instance ids to `{kind, enabled, self, scopes, receive_all}`. Chat additionally takes `mentions` (names without @); Mail takes `mail_agent`. The service stamps `owner_subject` from the authenticated owner, ignoring caller-supplied identities. Scope names are `global`, `channel:<id>`, `dm:<id>`, `inbox:<id>`, or configured time scope names. DMs must include both the bound agent identity and the owner. Mail events must name the owner's configured inbox and agent. Mail reads the original through the existing authorized Mail read client before entering the transaction, checks the server-stamped owner, inbox and sender, and groups replies by the References root. Chat room reply chains form Threads; separate top-level mentions do not share ownership. Own messages and events outside scope are dropped before domain intake. DM Events contain identifiers rather than text: the Trigger preserves these facts with a read pointer; workers must read the original through their authorized Chat connection.--Policies contain `mode` (`off|observe|auto`), `rules` (`id`, `enabled`, `instruction`), `budget` (`usd`, `tokens`, `wall_minutes`) and `sources`. Each Source policy has `allowed`, `bots` and an `admission` table keyed by scope, with `listed_unaddressed`, `unlisted_addressed` and `unlisted_unaddressed` cells. The first and last allow `inferred|ignore`; unlisted addressed also allows `explicit`. No identities are mapped between Sources.++Policies contain `mode` (`off|observe|auto`), `rules` (`id`, `enabled`, `instruction`, optional `mode` `observe|auto`), `budget` (`usd`, `tokens`, `wall_minutes`) and `sources`. A rule without a mode is in observe: a lane never inherits autonomy from a global switch by accident. Each Source policy has `allowed`, `bots` and an `admission` table keyed by scope, with `listed_unaddressed`, `unlisted_addressed` and `unlisted_unaddressed` cells. The first and last allow `inferred|ignore`; unlisted addressed also allows `explicit`. No identities are mapped between Sources.## Routing and workersA `start` command batches pending Triggers on one Thread. While that evaluation runs, new events form its next batch. Explicit candidates produce `route` work; inferred candidates produce `assess` work. The router only routes; it cannot replace a worker-owned follow-up with new work. It may stop a Ticket on an explicitly authorized person's message.--The assessor returns `decision`, `scope`, `direction` and `rule_assessments`. Direction contains `implementation_requested`, `target_basis` (`specified|existing-standard|undetermined`), `requires_design_decision` and `evidence` entries `{trigger_id, quote}`. Every quote must occur in the original Trigger and every enabled rule must be assessed exactly once. Inferred acceptance needs settled direction and a matched rule. Observe records `ignore` with `would_accept`, with no launch or reply. The `ignored_from_card` shortcut can only ignore, with a reason. A missing/failed assessment should return `unavailable`; delivery starts the timeout, and three delivered expiries end unavailable after two retries.++The assessor returns `decision`, `scope`, `direction` and `rule_assessments`. Direction contains `implementation_requested`, `target_basis` (`specified|existing-standard|undetermined`), `requires_design_decision` and `evidence` entries `{trigger_id, quote}`. Every quote must occur in the original Trigger and every enabled rule must be assessed exactly once. Inferred acceptance needs settled direction and a matched rule, and every matched lane in `auto`. Observe records `ignore` with `would_accept`, with no launch or reply. The `ignored_from_card` shortcut can only ignore, with a reason. A missing/failed assessment should return `unavailable`; delivery starts the timeout, and three delivered expiries end unavailable after two retries.Acceptance creates an immutable Mandate and `launch` work. Subscriptions deliver later events directly through `input` work without changing authority. Human own-thread messages stay with their workers until all owners hand them back, once each with a reason. `context` subscriptions neither own messages nor resume ended Tickets. `attach` requires the message to explicitly name the Ticket. A worker cannot unsubscribe from origin or Source-bound Threads. `read` is called only after a step consumed the input and a report/turn end followed; ending releases unread messages. Full session-level read accounting and report/stage integration are S6.@@@ -43,6 +43,87 @@ The task-to-Ticket binding is written in the task's own insert transaction (`actEach adapter must honor the stable idempotency key. Transport is at-least-once; a crash after sending and before settlement retries the same key. Expiring leases recover interrupted sends; stale lease tokens cannot settle newer claims. Retries start at 2 seconds and double to 60 seconds (replies: 5 to 300 seconds). Earlier work blocks later work in the same conversation. Other conversations proceed independently. Replies wait while their Source is off and drop only when its destination is no longer listened to or cannot receive. Drops retain their reason and time and cannot be requeued with the same key. Resume launches include a recovery instruction to inspect existing work before repeating outside actions.++## Observe then auto (S5)++++A lane is one entry in `policy.rules`. Its mode is `observe` or `auto`; a rule with no mode is in observe. The top-level++`mode` is the master switch: `off` admits no inferred work, `observe` holds every lane in observe, and `auto` lets each++lane follow its own mode. Explicit human asks are unaffected by lane modes. `promote` also sets the master to `auto`.++++In observe, an inferred evaluation is recorded in the aggregate (`state.observe`) and in `actor_fabric_observe` with the++gate's verdict and basis, the assessment direction and per-rule reasons, what the lane would have done, and the++estimated cost. Nothing is launched. The estimate is Actor's own data: the low and high USD cost of the agent's last++finished runs (at least three), with basis `the last N finished runs of this agent`. Fewer than three is `null` with++basis `no data yet`; a gate that admits nothing is 0 with `nothing would run`. The aggregate keeps the newest 500++records; every row is mirrored to SQL in the same transaction that commits the aggregate.++++Every promotion and demotion is written to `actor_fabric_lane_changes` with the actor (`owner` or `system`) and the reason++in words. Two things demote without the owner: a gate that cannot decide (`unavailable`) puts every auto lane back in++observe with `The gate could not decide: <reason>.`; a task report that reaches the mandate's USD budget puts the++Ticket's matched lanes back with `The job used its whole budget of $X.` Neither cancels work already running; the owner++re-promotes after reading the records.++++### Endpoints for the panel++++All are owner-only (`actor.agent.manage`); a foreign agent is 404. `GET /api/v1/fabric/:agent/lanes`:++++```json++{++ "agent": "agt_01JZ0000000000000000000AAA",++ "mode": "auto",++ "policy_version": "8f1c…",++ "budget": { "usd": 1, "tokens": 1000, "wall_minutes": 10 },++ "lanes": [{++ "rule_id": "fix",++ "instruction": "Fix requested defects",++ "enabled": true,++ "mode": "observe",++ "configured_mode": "observe",++ "promoted_at": null,++ "demoted_at": "2026-11-09T17:02:11.000Z",++ "demote_reason": "The job used its whole budget of $1.00. This lane is in observe until you promote it again.",++ "observe": { "count": 12, "last_at": "2026-11-09T17:01:58.000Z" },++ "last_change": { "from_mode": "auto", "to_mode": "observe", "reason": "The job used its whole budget of $1.00. This lane is in observe until you promote it again.", "actor": "system", "at": "2026-11-09T17:02:11.000Z" },++ "report_counts": { "progress": 3, "waiting": 0, "ready": 8, "blocked": 1, "failed": 1, "dropped": 0 }++ }],++ "report_states": [++ { "state": "progress", "label": "Working", "meaning": "The executor is working on the Ticket." },++ { "state": "waiting", "label": "Waiting", "meaning": "The executor is waiting for a trigger or an event." },++ { "state": "ready", "label": "Ready for review", "meaning": "The work is done and ready for the owner to review." },++ { "state": "blocked", "label": "Blocked", "meaning": "The executor cannot continue without a person." },++ { "state": "failed", "label": "Failed", "meaning": "The last run failed." },++ { "state": "dropped", "label": "Dropped", "meaning": "The work was stopped or dropped and will not continue." }++ ]++}++```++++`GET /api/v1/fabric/:agent/observe?lane=fix&limit=20` (`lane` optional, `limit` 1–200, newest first):++++```json++{++ "agent": "agt_01JZ0000000000000000000AAA",++ "lane": "fix",++ "records": [{++ "id": "observe_184", "seq": 184, "at": "2026-11-09T17:01:58.000Z", "thread": "thread_…",++ "mode": "observe", "trigger_ids": ["trigger_…"], "rule_ids": ["fix"],++ "gate": { "admitted": true, "strength": "inferred", "basis": "table", "decision": "accept" },++ "decision": "ignore", "would_accept": true,++ "scope": "Fix the broken test",++ "would_do": { "action": "launch", "summary": "Would have started work: Fix the broken test" },++ "estimate": { "low_usd": 0.02, "high_usd": 0.06, "basis": "the last 3 finished runs of this agent" },++ "demoted_rules": [], "reason": "Observe: nothing was launched. Promote this lane to auto to let it act.",++ "direction": { "implementation_requested": true, "target_basis": "specified", "requires_design_decision": false, "evidence": [{ "trigger_id": "trigger_…", "quote": "broken test" }] },++ "rule_assessments": [{ "rule_id": "fix", "matched": true, "reason": "The message names a broken test" }]++ }],++ "report_states": ["the same six objects as above"]++}++```++++`GET /api/v1/fabric/:agent/report-states` returns `{agent, report_states}` (the six objects above) for a legend that loads before any ticket. `GET /api/v1/fabric/:agent/reports` returns `{agent, tickets: [{ticket_id, state, stage, lanes, threads, reports}], report_states}`; each report is the stored `agent.report@1` (`progress|waiting|ready|blocked|failed|dropped`). The first row of `agent.stage@1` that matches the latest report is the ticket's `stage`.++++`POST /api/v1/fabric/:agent/commands/promote` with `{"lane":"fix"}` → `{"lane":"fix","mode":"auto","policy_version":"…","changed":true}`. The panel re-reads `/lanes` for the new `last_change`.++++`POST /api/v1/fabric/:agent/commands/demote` with `{"lane":"fix","reason":"Reading a few more runs first."}`, or `{"reason":"…"}` to demote every lane and the master switch → `{"lane":"fix","mode":"observe","policy_version":"…","changes":[{"rule_id":"fix","from_mode":"auto","to_mode":"observe","reason":"…","actor":"owner"}]}`.++Time belongs to `sources.js`, not assessment. A schedule is `{id, source, scope, due, text, interval_ms?}`; timestamps are epoch milliseconds. A quiet condition adds `{quiet_thread, since_trigger}`. The Source cancels it if that Thread changes before its due time. Time produces immutable system Triggers; admission requires `clock` in the Source's bot list and the scope in `receive_all`. Time never grants explicit authority. `tick` checks due conditions, expires evaluations and starts pending batches; it never sleeps or calls a model.--Account export/deletion includes the fabric aggregate and the task-to-Ticket binding. The expand-only migrations add no production writes during development. Tests name the corresponding rules from reference docs 001–003; `ov test test/fabric.test.js test/fabric-store.test.js test/fabric-adapters.test.js test/fabric-executors.test.js` exercises policy, intake, persistence, ownership, routing, delivery, adapters and one real run on the stand-in providers without external services.++Account export/deletion includes the fabric aggregate, the task-to-Ticket binding, the observe records and the lane changes. The expand-only migrations add no production writes during development. Tests name the corresponding rules from reference docs 001–003; `ov test test/fabric.test.js test/fabric-store.test.js test/fabric-adapters.test.js test/fabric-executors.test.js test/fabric-observe.test.js test/fabric-observe-store.test.js` exercises policy, intake, persistence, ownership, routing, delivery, adapters, observe records, promotion/demotion, the six report states and one real run on the stand-in providers without external services.nnew file mode 100644@@@ -0,0 +1,30 @@++-- phase: expand++-- S5 observe records: an append-only read model of what a lane would have done (the gate's verdict and the estimated++-- cost), plus every promotion/demotion of a lane with its reason. The agent's JSONB aggregate remains the transaction++-- boundary: service.js mirrors new records into these tables in the same transaction that commits the aggregate.++CREATE TABLE actor_fabric_observe (++ id TEXT PRIMARY KEY,++ agent_id TEXT NOT NULL REFERENCES actor_agents(id) ON DELETE CASCADE,++ owner TEXT NOT NULL,++ seq INTEGER NOT NULL,++ thread TEXT,++ rule_ids TEXT[] NOT NULL DEFAULT '{}',++ record JSONB NOT NULL,++ created_at TIMESTAMPTZ NOT NULL DEFAULT now()++);++CREATE INDEX actor_fabric_observe_agent ON actor_fabric_observe(agent_id, seq DESC);++CREATE INDEX actor_fabric_observe_rules ON actor_fabric_observe USING gin (rule_ids);++++CREATE TABLE actor_fabric_lane_changes (++ id TEXT PRIMARY KEY,++ agent_id TEXT NOT NULL REFERENCES actor_agents(id) ON DELETE CASCADE,++ owner TEXT NOT NULL,++ seq INTEGER NOT NULL,++ rule_id TEXT NOT NULL,++ from_mode TEXT NOT NULL,++ to_mode TEXT NOT NULL,++ reason TEXT NOT NULL,++ actor TEXT NOT NULL,++ created_at TIMESTAMPTZ NOT NULL DEFAULT now()++);++CREATE INDEX actor_fabric_lane_changes_agent ON actor_fabric_lane_changes(agent_id, seq DESC);@@@ -9,7 +9,7 @@ const path = require('path');const { createDb } = require('openvibe-sdk/db');const { ids } = require('openvibe-contracts');--const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_spends', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases', 'actor_mention_firings', 'actor_fabric_tasks'];++const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_spends', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases', 'actor_mention_firings', 'actor_fabric_tasks', 'actor_fabric_observe', 'actor_fabric_lane_changes'];const MIGRATIONS = path.join(__dirname, '..', 'migrations');const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');@@@ -273,7 +273,7 @@ function createFabricAdapters({ s, config, fabric, models, ctx: taskCtx = null,const ticket = state.tickets[ticketId];if (ticket.state !== 'open') return;const report = reportFor(row, ticket);-- await fabric.command(owner, agentId, 'report', { ticket_id: ticketId, report, end: true });++ await fabric.command(owner, agentId, 'report', { ticket_id: ticketId, report, end: true, spent_usd: Number(row.cost_usd) || 0, task_id: row.id });// One confirmation to the person on the Source thread, not a stream of progress.const source = state.sources[ticket.mandate.actor.source];if (source) {@@@ -2,6 +2,8 @@const express = require('express');const { asyncRouter } = require('../http/router');const contracts = require('openvibe-contracts');++const { reportStates, emptyReportCounts } = require('./reports');++const { laneMode } = require('./policy');function createRoutes(ctx) {const r = asyncRouter();r.use(express.json({ limit: '64kb' }));@@@ -17,6 +19,73 @@ function createRoutes(ctx) {catch (err) { if (err.status) return contracts.http.sendProblem(res, err.status, 'actor.fabric.refused', { detail: err.message, ctx: req.ov }); next(err); }};r.get('/:agent', run(req => ctx.fabric.snapshot(req.principal.requester, req.params.agent)));++++ /** What the panel needs for a trigger's mode: each lane, its observe count and its reports by state. */++ r.get('/:agent/lanes', run(async (req) => {++ const owner = req.principal.requester, agent = req.params.agent;++ const snap = await ctx.fabric.snapshot(owner, agent);++ const state = snap.state;++ const stats = await ctx.fabric.laneStats(owner, agent);++ const seen = new Map(stats.observe.map((x) => [x.rule_id, x]));++ const changed = new Map(stats.changes.map((x) => [x.rule_id, x]));++ const reports = new Map();++ for (const [ticketId, ticket] of Object.entries(state.tickets || {})) {++ const meta = (state.meta || {})[ticketId] || {};++ for (const report of meta.reports || []) {++ for (const lane of (ticket.mandate && ticket.mandate.matched_rules) || []) {++ if (!reports.has(lane)) reports.set(lane, emptyReportCounts());++ if (Object.hasOwn(reports.get(lane), report.state)) reports.get(lane)[report.state] += 1;++ }++ }++ }++ const lanes = (state.policy && state.policy.rules ? state.policy.rules : []).map((rule) => {++ const last = changed.get(rule.id);++ return {++ rule_id: rule.id,++ instruction: rule.instruction,++ enabled: rule.enabled !== false,++ mode: state.policy.mode === 'auto' ? laneMode(rule) : 'observe',++ configured_mode: laneMode(rule),++ promoted_at: rule.promoted_at || null,++ demoted_at: rule.demoted_at || null,++ demote_reason: rule.demote_reason || null,++ observe: { count: Number((seen.get(rule.id) || {}).count) || 0, last_at: (seen.get(rule.id) || {}).last_at || null },++ last_change: last ? { from_mode: last.from_mode, to_mode: last.to_mode, reason: last.reason, actor: last.actor, at: last.created_at } : null,++ report_counts: reports.get(rule.id) || emptyReportCounts(),++ };++ });++ return {++ agent, mode: state.policy ? state.policy.mode : 'observe', policy_version: state.policy ? state.policy.version : null,++ budget: state.policy ? state.policy.budget : null, lanes, report_states: reportStates(),++ };++ }));++++ /** What the panel needs for observe records: newest first, one lane or all of them. */++ r.get('/:agent/observe', run(async (req) => {++ const lane = req.query.lane == null || req.query.lane === '' ? null : String(req.query.lane).slice(0, 80);++ const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 50));++ const records = await ctx.fabric.observe(req.principal.requester, req.params.agent, { lane, limit });++ return { agent: req.params.agent, lane, records, report_states: reportStates() };++ }));++++ /** The six report states alone, for a panel that shows the legend before it loads a ticket. */++ r.get('/:agent/report-states', run(req => ({ agent: req.params.agent, report_states: reportStates() })));++++ /** The reports behind the six states, ticket by ticket. */++ r.get('/:agent/reports', run(async (req) => {++ const snap = await ctx.fabric.snapshot(req.principal.requester, req.params.agent);++ const state = snap.state;++ const tickets = Object.entries(state.tickets || {}).map(([id, ticket]) => ({++ ticket_id: id,++ state: ticket.state,++ stage: ticket.stage,++ lanes: (ticket.mandate && ticket.mandate.matched_rules) || [],++ threads: ticket.threads || [],++ reports: ((state.meta || {})[id] || {}).reports || [],++ }));++ return { agent: req.params.agent, tickets, report_states: reportStates() };++ }));++r.post('/:agent/commands/:command', run(req => ctx.fabric.command(req.principal.requester, req.params.agent, req.params.command, req.body)));return r;}@@@ -1,18 +1,99 @@'use strict';--const { policy, gate, revalidate, assessment, assert, text } = require('./policy');++const { policy, gate, revalidate, assessment, assert, text, laneMode } = require('./policy');++const { REPORT_STAGES } = require('./reports');const copy = structuredClone;const own = (map, key) => Object.hasOwn(map, key) ? map[key] : undefined;const emptyDirection = () => ({ implementation_requested: false, target_basis: 'undetermined', requires_design_decision: false, evidence: [] });--// The six agent.report@1 states and the stage each one leaves on an open Ticket (agent.stage@1, first match).--const REPORT_STAGES = Object.freeze({ ready: 'person/review', blocked: 'person/blocked', failed: 'person/failed', waiting: 'idle', progress: 'working', dropped: 'idle' });++const NO_ESTIMATE = Object.freeze({ low_usd: null, high_usd: null, basis: 'no data yet' });++const NOTHING_RUNS = Object.freeze({ low_usd: 0, high_usd: 0, basis: 'nothing would run' });++const round = (n) => Math.round((Number(n) || 0) * 1e6) / 1e6;++const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };function initial() {-- return { policy: policy(), sources: {}, triggers: {}, threads: {}, tickets: {}, meta: {}, decisions: [], work: [], schedules: {}, accepted: {}, seq: 0 };++ return { policy: policy(), sources: {}, triggers: {}, threads: {}, tickets: {}, meta: {}, decisions: [], work: [], schedules: {}, accepted: {}, seq: 0,++ // S5: what each lane would have done, and every promotion/demotion with its reason (mirrored to SQL by service.js).++ observe: [], observe_seq: 0, lane_changes: [], lane_seq: 0 };}// All methods are synchronous. The repository loads and locks an agent's aggregate, invokes exactly one command,// then commits its state and outbox together. No model, timer, promise or network port is reachable here.class Engine {-- constructor(state, { now = Date.now(), validate = () => {} } = {}) { this.s = state; this.now = now; this.validate = validate; }++ constructor(state, { now = Date.now(), validate = () => {}, estimate = null } = {}) {++ this.s = state; this.now = now; this.validate = validate;++ this.estimate = typeof estimate === 'function' ? estimate : () => NO_ESTIMATE;++ // Rows written before S5 have no observe state; they gain it on their next command.++ this.s.observe ||= []; this.s.observe_seq ||= 0; this.s.lane_changes ||= []; this.s.lane_seq ||= 0;++ }id(kind) { return `${kind}_${++this.s.seq}`; }++ iso() { return new Date(this.now).toISOString(); }++ lane(id) { return this.s.policy.rules.find(r => r.id === id) || null; }++ laneModeOf(id) { return laneMode(this.lane(id)); }++ /** The cost range the estimate port gives for this lane, or the honest "no data yet". */++ estimateFor(lanes, triggers, scope) {++ try {++ const out = this.estimate({ lanes, triggers, scope, policy: this.s.policy });++ return out && typeof out === 'object' ? { low_usd: out.low_usd == null ? null : round(out.low_usd), high_usd: out.high_usd == null ? null : round(out.high_usd), basis: String(out.basis || 'no data yet') } : { ...NO_ESTIMATE };++ } catch { return { ...NO_ESTIMATE }; }++ }++ /** One observe record. The aggregate keeps the newest 500; service.js mirrors every row to SQL in the transaction. */++ recordObserve(entry) {++ const seq = ++this.s.observe_seq;++ const record = { id: `observe_${seq}`, seq, at: this.iso(), ...entry };++ this.s.observe.push(record);++ if (this.s.observe.length > 500) this.s.observe.splice(0, this.s.observe.length - 500);++ return record;++ }++ recordLaneChange(change) {++ const seq = ++this.s.lane_seq;++ this.s.lane_changes.push({ id: `lane_${seq}`, seq, at: this.iso(), ...change });++ if (this.s.lane_changes.length > 200) this.s.lane_changes.splice(0, this.s.lane_changes.length - 200);++ }++ /** Take lanes out of auto with one policy version change; every lane that actually moved gets an audit row. */++ demoteRules(ids, reason, actor = 'system') {++ const why = text(reason) ? String(reason).trim() : 'This lane is back in observe.';++ const wanted = [...new Set(ids || [])].filter(id => this.lane(id));++ const changes = [];++ if (!wanted.length) return changes;++ const next = copy(this.s.policy);++ for (const r of next.rules) {++ if (!wanted.includes(r.id) || laneMode(r) === 'observe') continue;++ r.mode = 'observe'; r.demoted_at = this.iso(); r.demote_reason = why; r.demoted_by = actor;++ changes.push({ rule_id: r.id, from_mode: 'auto', to_mode: 'observe', reason: why, actor });++ }++ if (!changes.length) return changes;++ if (!next.rules.some(r => laneMode(r) === 'auto')) next.mode = 'observe';++ this.configure({ policy: next });++ for (const c of changes) this.recordLaneChange(c);++ return changes;++ }++ /** The owner moves one lane to auto after reading its observe records. */++ promote({ lane }) {++ assert(text(lane), 'Name the lane to promote');++ const rule = this.lane(lane);++ assert(rule, 'Unknown lane');++ if (laneMode(rule) === 'auto') return { lane, mode: 'auto', policy_version: this.s.policy.version, changed: false };++ const next = copy(this.s.policy);++ next.mode = 'auto';++ const target = next.rules.find(r => r.id === lane);++ target.mode = 'auto'; target.promoted_at = this.iso(); target.promoted_by = 'owner';++ delete target.demoted_at; delete target.demote_reason; delete target.demoted_by;++ this.configure({ policy: next });++ this.recordLaneChange({ rule_id: lane, from_mode: 'observe', to_mode: 'auto', reason: 'The owner promoted this lane after reviewing its observe records.', actor: 'owner' });++ return { lane, mode: 'auto', policy_version: this.s.policy.version, changed: true };++ }++ /** The owner moves one lane (or the whole agent) back to observe; the reason is said in words. */++ demote({ lane = null, reason = null }) {++ assert(lane == null || text(lane), 'Name a lane or demote the whole agent');++ assert(reason == null || text(reason), 'Say why in words');++ if (lane != null) {++ assert(this.lane(lane), 'Unknown lane');++ const changes = this.demoteRules([lane], reason || 'The owner switched this lane back to observe.', 'owner');++ return { lane, mode: this.laneModeOf(lane), policy_version: this.s.policy.version, changes };++ }++ const changes = this.demoteRules(this.s.policy.rules.map(r => r.id), reason || 'The owner switched the agent back to observe.', 'owner');++ if (this.s.policy.mode !== 'observe') {++ const next = copy(this.s.policy); next.mode = 'observe'; this.configure({ policy: next });++ }++ return { lane: null, mode: 'observe', policy_version: this.s.policy.version, changes };++ }thread(key) { assert(text(key) && !['__proto__', 'constructor', 'prototype'].includes(key), 'Invalid Thread'); return this.s.threads[key] ||= { pending: [], subscriptions: {}, evaluation: null, history: [] }; }queue(t) {const th = this.thread(t.threads[0]);@@@ -100,7 +181,18 @@ class Engine {const best = candidates[0];th.pending = [];th.evaluation = { id: this.id('eval'), ids, primary: best?.id || ids[0], strength: best?.result.strength || 'inferred', basis: best?.result.basis, policy_version: this.s.policy.version, attempts: th.expiries || 0 };-- if (!best) { this.finish(th, 'ignore', { reason: 'not_admitted' }); return null; }++ if (!best) {++ this.finish(th, 'ignore', { reason: 'not_admitted' });++ // A gate that admits nothing is still a verdict the owner can read. It has no lane (first match decided++ // nothing), so the record belongs to the Thread and says what would have happened: nothing.++ if (this.s.policy.mode !== 'off') this.recordObserve({++ thread, mode: 'observe', trigger_ids: ids, rule_ids: [], gate: { admitted: false, strength: 'inferred' },++ decision: 'ignore', would_accept: false, scope: null,++ would_do: { action: 'ignore', summary: 'The gate did not admit this message, so nothing would run.' },++ estimate: { ...NOTHING_RUNS }, reason: 'Not admitted by the deterministic gate.',++ });++ return null;++ }const e = copy(th.evaluation);// Explicit candidates go to the deterministic routing port, never the assessor.this.enqueue(e.strength === 'explicit' ? 'route' : 'assess', `candidate:${e.id}`, { evaluation: e, triggers: ids.map(id => this.s.triggers[id]), tickets: Object.values(this.s.tickets).filter(k => k.threads.includes(thread)), policy: this.s.policy, handbacks: Object.values(this.s.meta).flatMap(m => m.handbacks).filter(h => ids.includes(h.trigger_id)) }, `router:${thread}`);@@@ -131,7 +223,45 @@ class Engine {assert(e.strength === 'inferred' && decision === 'ignore' && text(reason), 'Card shortcut only ignores inferred work with a reason');audit.ignored_from_card = true;} else assert(!a, 'Explicit candidates do not use assessment');-- if (decision === 'accept' && e.strength === 'inferred' && this.s.policy.mode !== 'auto') { decision = 'ignore'; audit.would_accept = true; }++ const lanes = e.strength === 'inferred' ? matched.map(id => this.lane(id)).filter(Boolean) : [];++ const anyObserve = lanes.some(r => laneMode(r) !== 'auto');++ const preDecision = decision;++ if (decision === 'accept' && e.strength === 'inferred' && (this.s.policy.mode !== 'auto' || anyObserve)) {++ decision = 'ignore'; audit.would_accept = true;++ }++ let observe = null;++ if (e.strength === 'inferred') {++ const observing = decision === 'ignore' && audit.would_accept === true;++ const accepted = decision === 'accept';++ const scopeText = text(scope) ? scope : null;++ // A gate that cannot decide must not keep an auto lane running on its own judgement. The demotion runs++ // after this evaluation is finished: a policy change discards live evaluations by design.++ const demote = decision === 'unavailable'++ ? { ids: this.s.policy.rules.filter(r => laneMode(r) === 'auto').map(r => r.id), reason: `The gate could not decide: ${clip(audit.reason || 'the assessment was unavailable', 200)}. This lane is in observe until you promote it again.` }++ : null;++ const estimate = observing || accepted++ ? this.estimateFor(lanes, e.ids.map(id => this.s.triggers[id]), scopeText)++ : { ...NOTHING_RUNS };++ const why = observing ? 'Observe: nothing was launched. Promote this lane to auto to let it act.'++ : accepted ? 'Auto: the work was launched.'++ : decision === 'unavailable' ? clip(audit.reason || 'The gate could not decide, so nothing ran.', 400)++ : clip(audit.reason || 'No enabled rule matched, so nothing would run.', 400);++ observe = this.recordObserve({++ thread: Object.keys(this.s.threads).find(key => this.s.threads[key] === th) || null,++ mode: this.s.policy.mode === 'auto' && lanes.length && lanes.every(r => laneMode(r) === 'auto') ? 'auto' : 'observe',++ trigger_ids: e.ids,++ rule_ids: lanes.map(r => r.id),++ gate: { admitted: true, strength: e.strength, basis: e.basis || null, decision: preDecision },++ decision, would_accept: audit.would_accept === true,++ scope: scopeText, direction: audit.direction || emptyDirection(),++ rule_assessments: audit.rule_assessments || [],++ would_do: observing ? { action: 'launch', summary: `Would have started work: ${clip(scopeText || 'the matched work', 300)}` }++ : accepted ? { action: 'launch', summary: `Started work: ${clip(scopeText || 'the matched work', 300)}` }++ : { action: 'ignore', summary: why },++ estimate, demoted_rules: [], reason: why,++ });++ observe.demote = demote;++ }let ticket;if (decision === 'accept') {assert(text(scope), 'Acceptance needs a scope');@@@ -146,7 +276,12 @@ class Engine {this.enqueue('launch', `launch:${ticket.id}`, { ticket, triggers: e.ids.map(id => this.s.triggers[id]) }, `worker:${ticket.id}`);}th.expiries = 0;-- return { decision: this.finish(th, decision, audit), ...(ticket ? { ticket } : {}) };++ const finished = this.finish(th, decision, audit);++ if (observe && observe.demote) {++ observe.demoted_rules = this.demoteRules(observe.demote.ids, observe.demote.reason).map(c => c.rule_id);++ delete observe.demote;++ }++ return { decision: finished, ...(ticket ? { ticket } : {}), ...(observe ? { observe } : {}) };}/** An executor port bound the Ticket to the task that runs it (the last run of a follow-up is the current one). */bind({ ticket_id, task_id }) {@@@ -158,17 +293,31 @@ class Engine {return k;}/** Record one agent.report@1 on the Ticket; a terminal report may end it. */-- report({ ticket_id, report, end = false }) {++ report({ ticket_id, report, end = false, spent_usd = 0, task_id = null }) {const k = own(this.s.tickets, ticket_id), m = own(this.s.meta, ticket_id);assert(k && m && report && typeof report === 'object' && !Array.isArray(report), 'Report needs an open Ticket');assert(k.state === 'open', 'Ticket has ended');++ assert(Number.isFinite(Number(spent_usd)) && Number(spent_usd) >= 0, 'Invalid spend');++ m.spent_tasks ||= [];++ // A retried task end reports once: the spend stays the task's own, not a second copy.++ if (task_id && m.spent_tasks.includes(task_id)) return { ticket: k, report: m.reports[m.reports.length - 1] || null, spent_usd: m.spent_usd, repeated: true };++ if (task_id) m.spent_tasks.push(task_id);const r = copy({ ...report, ticket_id });this.validate('agent.report', r);m.reports ||= [];m.reports.push(r);++ m.spent_usd = round(Number(m.spent_usd || 0) + Number(spent_usd || 0));if (REPORT_STAGES[r.state]) k.stage = REPORT_STAGES[r.state];++ // The mandate's budget is a cap, not advice: a spent budget puts the lane that produced the Ticket back in++ // observe, with the number said in words, and the owner decides whether to promote it again.++ const budget = Number(k.mandate && k.mandate.budget && k.mandate.budget.usd) || 0;++ if (budget > 0 && m.spent_usd >= budget - 1e-9) {++ const why = `The job used its whole budget of $${budget.toFixed(2)}. This lane is in observe until you promote it again.`;++ m.budget_stopped = { at: this.iso(), reason: why, spent_usd: m.spent_usd };++ this.demoteRules(k.mandate.matched_rules || [], why, 'system');++ }if (end) this.end({ ticket_id, reason: `report:${r.state}` });-- return { ticket: k, report: r };++ return { ticket: k, report: r, spent_usd: m.spent_usd };}subscribe({ ticket_id, thread, kind = 'context' }) {const k = own(this.s.tickets, ticket_id);@@@ -6,7 +6,7 @@ function policy(input = {}) {const p = structuredClone({ mode: 'observe', rules: [], sources: {}, budget: { usd: 0, tokens: 0, wall_minutes: 0 }, ...input });delete p.version;assert(['off', 'observe', 'auto'].includes(p.mode), 'Invalid accept mode');-- assert(Array.isArray(p.rules) && p.rules.every(r => text(r.id) && text(r.instruction) && typeof r.enabled === 'boolean') && new Set(p.rules.map(r => r.id)).size === p.rules.length, 'Rules need unique ids, instructions and enabled flags');++ assert(Array.isArray(p.rules) && p.rules.every(r => text(r.id) && text(r.instruction) && typeof r.enabled === 'boolean' && (r.mode == null || ['observe', 'auto'].includes(r.mode))) && new Set(p.rules.map(r => r.id)).size === p.rules.length, 'Rules need unique ids, instructions, enabled flags and an observe or auto mode');assert(p.budget && ['usd', 'tokens', 'wall_minutes'].every(k => Number.isFinite(p.budget[k]) && p.budget[k] >= 0) && Number.isInteger(p.budget.tokens), 'Invalid mandate budget');assert(p.sources && typeof p.sources === 'object' && !Array.isArray(p.sources), 'Invalid source policies');for (const s of Object.values(p.sources)) {@@@ -67,4 +67,6 @@ function assessment(a, p, triggers) {if (accepted) assert(text(a.scope), 'Acceptance needs a scope');return { matched, accepted };}--module.exports = { policy, gate, revalidate, assessment, assert, text };++// A lane without an explicit mode is in observe: lanes never inherit autonomy from a global switch by accident.++const laneMode = (rule) => (rule && rule.mode === 'auto' ? 'auto' : 'observe');++module.exports = { policy, gate, revalidate, assessment, assert, text, laneMode };nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++++/**++ * The six agent.report@1 states, as data for the panel (plan W1/W9, S5). A report is the executor's own account of a++ * Ticket; a ticket stage is computed from the latest report by the first matching row of agent.stage@1. The states are++ * closed: an executor that wants another word is asking for a contract change, not a new string.++ *++ * REPORT_STATES [{state, label, meaning}] in contract order++ * REPORT_STATE_VALUES the six values alone (for checks and counts)++ * REPORT_STAGES the stage each state leaves on an open Ticket++ * reportStates() a copy for an API response++ * emptyReportCounts() all six at zero, for a lane's per-state summary++ */++const REPORT_STATES = Object.freeze([++ { state: 'progress', label: 'Working', meaning: 'The executor is working on the Ticket.' },++ { state: 'waiting', label: 'Waiting', meaning: 'The executor is waiting for a trigger or an event.' },++ { state: 'ready', label: 'Ready for review', meaning: 'The work is done and ready for the owner to review.' },++ { state: 'blocked', label: 'Blocked', meaning: 'The executor cannot continue without a person.' },++ { state: 'failed', label: 'Failed', meaning: 'The last run failed.' },++ { state: 'dropped', label: 'Dropped', meaning: 'The work was stopped or dropped and will not continue.' },++]);++const REPORT_STATE_VALUES = Object.freeze(REPORT_STATES.map((s) => s.state));++const REPORT_STAGES = Object.freeze({ ready: 'person/review', blocked: 'person/blocked', failed: 'person/failed', waiting: 'idle', progress: 'working', dropped: 'idle' });++++const reportStates = () => REPORT_STATES.map((s) => ({ ...s }));++const emptyReportCounts = () => Object.fromEntries(REPORT_STATE_VALUES.map((s) => [s, 0]));++++module.exports = { REPORT_STATES, REPORT_STATE_VALUES, REPORT_STAGES, reportStates, emptyReportCounts };@@@ -3,13 +3,47 @@ const contracts = require('openvibe-contracts');const { Engine, initial } = require('./engine');const sources = require('./sources');const { assert } = require('./policy');--const COMMANDS = new Set(['configure', 'start', 'decide', 'subscribe', 'attach', 'unsubscribe', 'handBack', 'read', 'end', 'stop', 'reply', 'claim', 'settle', 'delivered', 'bind', 'report']);++const COMMANDS = new Set(['configure', 'start', 'decide', 'subscribe', 'attach', 'unsubscribe', 'handBack', 'read', 'end', 'stop', 'reply', 'claim', 'settle', 'delivered', 'bind', 'report', 'promote', 'demote']);++const round = (n) => Math.round((Number(n) || 0) * 1e6) / 1e6;++// The first promotion review needs a little history; a single run is not a basis for an estimate.++const ESTIMATE_RUNS = 3;function createFabric({ s, enabled = false, readMail, validate = (name, value) => {try { return contracts.assertValid(`${name}@1`, value); }catch (err) { if (err.code === 'contract.invalid') err.status = 422; throw err; }} }) {const enabledCheck = () => { if (!enabled) throw Object.assign(new Error('Work fabric is disabled'), { status: 404 }); };-- async function transact(owner, agent, command) {++++ /** The estimate port for a decide: Actor's own finished runs of this agent, or "no data yet". */++ async function estimator(owner, agent) {++ try {++ const rows = await s.db.many(`SELECT cost_usd FROM tasks WHERE requester = $1 AND via = $2 AND state = 'succeeded'++ AND finished_at IS NOT NULL ORDER BY finished_at DESC, id DESC LIMIT 20`, [owner, `agent:${agent}`]);++ const costs = rows.map(r => Number(r.cost_usd)).filter(v => Number.isFinite(v) && v > 0);++ if (costs.length < ESTIMATE_RUNS) return null;++ const sorted = [...costs].sort((a, b) => a - b);++ return () => ({ low_usd: round(sorted[0]), high_usd: round(sorted[sorted.length - 1]), basis: `the last ${sorted.length} finished runs of this agent` });++ } catch { return null; }++ }++++ /** Observe records and lane changes are mirrored from the aggregate in the same transaction that commits it. */++ async function mirror(db, agent, owner, state) {++ const lastObserve = Number(await db.value('SELECT COALESCE(MAX(seq), 0) FROM actor_fabric_observe WHERE agent_id = $1', [agent])) || 0;++ for (const r of state.observe || []) {++ if (Number(r.seq) <= lastObserve) continue;++ await db.query(`INSERT INTO actor_fabric_observe (id, agent_id, owner, seq, thread, rule_ids, record, created_at)++ VALUES ($1,$2,$3,$4,$5,$6::text[],$7::jsonb,$8) ON CONFLICT (id) DO NOTHING`,++ [r.id, agent, owner, Number(r.seq), r.thread || null, r.rule_ids || [], JSON.stringify(r), r.at || s.iso()]);++ }++ const lastLane = Number(await db.value('SELECT COALESCE(MAX(seq), 0) FROM actor_fabric_lane_changes WHERE agent_id = $1', [agent])) || 0;++ for (const c of state.lane_changes || []) {++ if (Number(c.seq) <= lastLane) continue;++ await db.query(`INSERT INTO actor_fabric_lane_changes (id, agent_id, owner, seq, rule_id, from_mode, to_mode, reason, actor, created_at)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (id) DO NOTHING`,++ [c.id, agent, owner, Number(c.seq), c.rule_id, c.from_mode, c.to_mode, c.reason, c.actor || 'system', c.at || s.iso()]);++ }++ }++++ async function transact(owner, agent, command, options = {}) {enabledCheck();return s.db.tx(async db => {// Authorization is inside the transaction and is repeated for every command, including leases.@@@ -17,9 +51,10 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =if (!exists) throw Object.assign(new Error('Agent not found'), { status: 404 });await db.query('INSERT INTO actor_work_fabric (agent_id, owner, state) VALUES ($1,$2,$3::jsonb) ON CONFLICT DO NOTHING', [agent, owner, JSON.stringify(initial())]);const row = await db.one('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2 FOR UPDATE', [agent, owner]);-- const engine = new Engine(row.state, { now: s.now(), validate });++ const engine = new Engine(row.state, { now: s.now(), validate, estimate: options.estimate || null });const result = command(engine);assert(!result || typeof result.then !== 'function', 'Engine commands must be synchronous');++ await mirror(db, agent, owner, engine.s);await db.query('UPDATE actor_work_fabric SET state = $3::jsonb, revision = revision + 1, updated_at = $4 WHERE agent_id = $1 AND owner = $2', [agent, owner, JSON.stringify(engine.s), s.iso()]);return structuredClone(result ?? null);});@@@ -32,8 +67,9 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });return row;},-- command(owner, agent, name, args = {}) {++ async command(owner, agent, name, args = {}) {assert(COMMANDS.has(name) || ['schedule', 'cancelSchedule', 'tick'].includes(name), 'Unknown fabric command');++ const options = name === 'decide' ? { estimate: await estimator(owner, agent) } : {};return transact(owner, agent, engine => {if (name === 'schedule') return sources.schedule(engine, args);if (name === 'cancelSchedule') { assert(Object.hasOwn(engine.s.schedules, args.id), 'Unknown schedule'); engine.s.schedules[args.id].cancelled = true; return true; }@@@ -48,7 +84,31 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =}}return engine[name](args);-- });++ }, options);++ },++ /** Observe records newest first, optionally for one lane. */++ async observe(owner, agent, { lane = null, limit = 50 } = {}) {++ enabledCheck();++ assert(!lane || typeof lane === 'string', 'Invalid lane');++ assert(Number.isInteger(limit) && limit >= 1 && limit <= 200, 'Invalid observe limit');++ const row = await s.db.maybe('SELECT state, revision FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2', [agent, owner]);++ if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });++ const rows = lane++ ? await s.db.many('SELECT record FROM actor_fabric_observe WHERE agent_id = $1 AND owner = $2 AND $3 = ANY(rule_ids) ORDER BY seq DESC LIMIT $4', [agent, owner, lane, limit])++ : await s.db.many('SELECT record FROM actor_fabric_observe WHERE agent_id = $1 AND owner = $2 ORDER BY seq DESC LIMIT $3', [agent, owner, limit]);++ return rows.map(r => r.record);++ },++ /** Per-lane observe counts and the last change, for the panel's lane row. */++ async laneStats(owner, agent) {++ enabledCheck();++ const row = await s.db.maybe('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2', [agent, owner]);++ if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });++ const observe = await s.db.many(`SELECT rule_id, count(*)::int AS count, max(created_at) AS last_at++ FROM (SELECT unnest(rule_ids) AS rule_id, created_at FROM actor_fabric_observe WHERE agent_id = $1 AND owner = $2) r++ GROUP BY rule_id`, [agent, owner]);++ const changes = await s.db.many(`SELECT DISTINCT ON (rule_id) rule_id, from_mode, to_mode, reason, actor, created_at++ FROM actor_fabric_lane_changes WHERE agent_id = $1 AND owner = $2 ORDER BY rule_id, seq DESC`, [agent, owner]);++ return { state: row.state, observe, changes };},worker(owner, agent, ticket) {const allowed = new Set(['handBack', 'read', 'end', 'subscribe', 'unsubscribe', 'reply']);@@@ -104,6 +104,9 @@ const TABLES = [{ table: 'actor_work_fabric', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric.json', erase: 'delete' },// The Actor runtime task behind a fabric Ticket (S4 executor adapters); the task row itself is exported above.{ table: 'actor_fabric_tasks', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-tasks.json', erase: 'delete' },++ // What a lane in observe would have done and every promotion/demotion with its reason (S5).++ { table: 'actor_fabric_observe', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-observe.json', erase: 'delete' },++ { table: 'actor_fabric_lane_changes', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-lane-changes.json', erase: 'delete' },{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },// The voices they shared (agents/voice-shares.js): the public link, the document it holds and who shared it.// The document is their own text, and the link is theirs to stop sharing.nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++/**++ * S5 observe → auto through the service and the API on a real (PGlite) database: observe records are mirrored to SQL,++ * the lanes endpoint says each lane's mode, promote/demote are commands, and the six report states are data.++ */++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const contacts = require('../server/agents/contacts');++const { createRoutes } = require('../server/fabric/api');++++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const policy = () => ({++ mode: 'observe', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }],++ sources: { chat: { allowed: [A], bots: [], admission: { global: { unlisted_unaddressed: 'inferred' } } } },++ budget: { usd: 1, tokens: 1000, wall_minutes: 10 },++});++++(async () => {++ const t = await boot({ env: { ACTOR_WORK_FABRIC: '1' } });++ try {++ assert.equal(typeof createRoutes, 'function');++ const alice = t.network.addUser('alice'); alice.subject = A;++ const bob = t.network.addUser('bob'); bob.subject = B;++ const vibe = await contacts.ensureVibe(t.ctx.s, t.config, `user:${A}`);++ const owner = `user:${A}`, agent = vibe.id, fabric = t.ctx.fabric;++ const event = (id, text) => ({ event_id: `ev${id}`, event_type: 'chat.message.created', payload: { message_id: id, user_subject: A, text, room: { type: 'global' } } });++ let first = null;++++ await check('observe: an inferred decision records the gate verdict and cost and launches nothing', async () => {++ await fabric.command(owner, agent, 'configure', {++ sources: { chat: { kind: 'chat', enabled: true, self: 'agent_actor', scopes: ['global'], receive_all: ['global'] } },++ policy: policy(),++ });++ await fabric.onEvent(event(1, 'The broken test fails'));++ await fabric.tick();++ let state = (await fabric.snapshot(owner, agent)).state;++ const thread = Object.keys(state.threads)[0];++ assert.equal(state.threads[thread].evaluation.strength, 'inferred');++ const triggerId = Object.keys(state.triggers)[0];++ const assessment = {++ decision: 'accept', scope: 'Fix the broken test',++ direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: triggerId, quote: 'broken test' }] },++ rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'The message names a broken test' }],++ };++ const out = await fabric.command(owner, agent, 'decide', { evaluation: state.threads[thread].evaluation.id, decision: 'accept', assessment });++ assert.equal(out.decision.decision, 'ignore'); assert.equal(out.decision.would_accept, true);++ assert.equal(Object.keys((await fabric.snapshot(owner, agent)).state.tickets).length, 0);++ const records = await fabric.observe(owner, agent, {});++ assert.equal(records.length, 1);++ assert.equal(records[0].gate.strength, 'inferred'); assert.equal(records[0].would_accept, true);++ assert.deepEqual(records[0].rule_ids, ['fix']); assert.equal(records[0].estimate.basis, 'no data yet');++ const mirrored = await t.ctx.s.db.value('SELECT count(*)::int FROM actor_fabric_observe WHERE agent_id = $1', [agent]);++ assert.equal(mirrored, 1);++ });++++ await check('lanes API: the panel gets each lane\'s mode, observe count and the six report states', async () => {++ const res = await t.get(`/api/v1/fabric/${agent}/lanes`, { as: alice });++ assert.equal(res.status, 200);++ const body = res.json();++ assert.equal(body.mode, 'observe'); assert.equal(body.lanes.length, 1);++ assert.equal(body.lanes[0].rule_id, 'fix'); assert.equal(body.lanes[0].mode, 'observe');++ assert.equal(body.lanes[0].observe.count, 1);++ assert.deepEqual(body.report_states.map(s => s.state), ['progress', 'waiting', 'ready', 'blocked', 'failed', 'dropped']);++ const obs = await t.get(`/api/v1/fabric/${agent}/observe?lane=fix&limit=10`, { as: alice });++ assert.equal(obs.status, 200); assert.equal(obs.json().records.length, 1);++ assert.equal(obs.json().records[0].decision, 'ignore');++ });++++ await check('promote: the owner moves the lane to auto, and the next inferred work launches', async () => {++ const res = await t.get(`/api/v1/fabric/${agent}/commands/promote`, { as: alice, json: { lane: 'fix' }, headers: { Origin: 'https://openvibe.actor' } });++ assert.equal(res.status, 200); assert.equal(res.json().mode, 'auto');++ const lanes = (await t.get(`/api/v1/fabric/${agent}/lanes`, { as: alice })).json();++ assert.equal(lanes.mode, 'auto'); assert.equal(lanes.lanes[0].mode, 'auto');++ assert.match(lanes.lanes[0].last_change.reason, /promoted this lane/);++ await fabric.onEvent(event(2, 'The next broken test fails'));++ await fabric.tick();++ const state = (await fabric.snapshot(owner, agent)).state;++ const thread = Object.keys(state.threads).find((key) => state.threads[key].evaluation);++ const triggerId = state.threads[thread].evaluation.primary;++ const assessment = { decision: 'accept', scope: 'Fix the next broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: triggerId, quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Names a broken test' }] };++ const out = await fabric.command(owner, agent, 'decide', { evaluation: state.threads[thread].evaluation.id, decision: 'accept', assessment });++ assert.equal(out.decision.decision, 'accept'); assert.ok(out.ticket);++ first = out.ticket;++ const after = await fabric.observe(owner, agent, { lane: 'fix' });++ assert.equal(after[0].mode, 'auto'); assert.equal(after[0].decision, 'accept');++ });++++ await check('spent budget: a report that uses the mandate budget demotes the lane with the number in words', async () => {++ const res = await t.get(`/api/v1/fabric/${agent}/commands/report`, { as: alice, json: { ticket_id: first.id, spent_usd: 1, report: { ticket_id: first.id, assignment_id: 'asg_1', state: 'progress', summary: 'Working', pr: null, tests_run: [], risks: [] } }, headers: { Origin: 'https://openvibe.actor' } });++ assert.equal(res.status, 200);++ const lanes = (await t.get(`/api/v1/fabric/${agent}/lanes`, { as: alice })).json();++ assert.equal(lanes.lanes[0].mode, 'observe');++ assert.match(lanes.lanes[0].demote_reason, /used its whole budget of \$1\.00/);++ assert.equal(lanes.lanes[0].report_counts.progress, 1);++ const changes = await t.ctx.s.db.many('SELECT * FROM actor_fabric_lane_changes WHERE agent_id = $1 ORDER BY seq', [agent]);++ assert.equal(changes.length, 2);++ assert.equal(changes.at(-1).actor, 'system');++ });++++ await check('reports API and the six states: ticket reports are data, not prose', async () => {++ const body = (await t.get(`/api/v1/fabric/${agent}/reports`, { as: alice })).json();++ assert.equal(body.tickets.length, 1); assert.equal(body.tickets[0].reports[0].state, 'progress');++ assert.equal(body.report_states.length, 6);++ const states = (await t.get(`/api/v1/fabric/${agent}/report-states`, { as: alice })).json();++ assert.deepEqual(states.report_states.map(s => s.state), ['progress', 'waiting', 'ready', 'blocked', 'failed', 'dropped']);++ assert.equal((await t.get(`/api/v1/fabric/${agent}/commands/demote`, { as: alice, json: { lane: 'fix', reason: 'Reading a few more observe records first.' }, headers: { Origin: 'https://openvibe.actor' } })).status, 200);++ });++++ await check('owner-only: another signed-in person gets 404 from every new read', async () => {++ for (const path of ['lanes', 'observe', 'reports']) assert.equal((await t.get(`/api/v1/fabric/${agent}/${path}`, { as: bob })).status, 404);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++/**++ * S5 observe → auto (plan W4): lanes start in observe, record what they would have done with the gate's verdict and an++ * estimated cost while doing nothing; the owner promotes one lane to auto; a failed gate or a spent budget puts it++ * back in observe with the reason said in words. The six report states are data.++ */++const assert = require('node:assert/strict');++const { Engine, initial } = require('../server/fabric/engine');++const { REPORT_STATES, REPORT_STATE_VALUES, REPORT_STAGES, reportStates, emptyReportCounts } = require('../server/fabric/reports');++++const cfg = () => ({++ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['room'], receive_all: ['room'] } },++ policy: {++ mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }],++ sources: { chat: { allowed: ['alice'], bots: [], admission: { room: { unlisted_addressed: 'inferred', unlisted_unaddressed: 'inferred' } } } },++ budget: { usd: 1, tokens: 1000, wall_minutes: 10 },++ },++});++const trigger = (id = 'a', extra = {}) => ({ id, source: 'chat', actor: { source: 'chat', id: 'bob', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'none', ...extra });++const good = () => ({ decision: 'accept', scope: 'Fix the broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Requested a defect fix' }] });++const engine = (options = {}) => {++ const e = new Engine(initial(), { now: 1000, estimate: () => ({ low_usd: 0.02, high_usd: 0.06, basis: 'the last 3 finished runs of this agent' }), ...options });++ e.configure(cfg());++ return e;++};++const assessOnce = (e) => { e.receive(trigger()); const ev = e.start({ thread: 'room' }); return { ev, decision: e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }) }; };++const report = (ticket, state = 'progress', extra = {}) => ({ ticket_id: ticket.id, assignment_id: 'asg_1', state, summary: state === 'ready' ? 'Done' : 'Working', pr: null, tests_run: [], risks: [], ...extra });++++let count = 0;++function test(name, fn) { fn(); count++; console.log(`ok ${name}`); }++++test('lane default: a rule without a mode starts in observe, records would-accept and launches nothing', () => {++ const e = engine(), { decision } = assessOnce(e);++ assert.equal(decision.decision.decision, 'ignore'); assert.equal(decision.decision.would_accept, true);++ assert(!e.s.work.some(w => ['launch', 'reply'].includes(w.kind)));++ assert.equal(e.s.observe.length, 1);++ const [o] = e.s.observe;++ assert.deepEqual(o.rule_ids, ['fix']); assert.equal(o.mode, 'observe');++ assert.deepEqual(o.gate, { admitted: true, strength: 'inferred', basis: 'table', decision: 'accept' });++ assert.equal(o.would_accept, true); assert.deepEqual(o.estimate, { low_usd: 0.02, high_usd: 0.06, basis: 'the last 3 finished runs of this agent' });++ assert.match(o.would_do.summary, /Would have started work/); assert.match(o.reason, /Observe/);++});++++test('promote and demote: one lane moves to auto by the owner and back with the reason said in words', () => {++ const e = engine();++ const promoted = e.promote({ lane: 'fix' });++ assert.equal(promoted.mode, 'auto'); assert.equal(e.s.policy.mode, 'auto'); assert.equal(e.s.policy.rules[0].mode, 'auto');++ assert.equal(e.s.lane_changes[0].from_mode, 'observe'); assert.equal(e.s.lane_changes[0].actor, 'owner');++ const { decision } = assessOnce(e);++ assert.equal(decision.decision.decision, 'accept'); assert(e.s.work.some(w => w.kind === 'launch'));++ const demoted = e.demote({ lane: 'fix', reason: 'The owner wants to read a few more runs first.' });++ assert.equal(demoted.mode, 'observe'); assert.equal(e.s.policy.rules[0].mode, 'observe');++ assert.equal(e.s.policy.rules[0].demote_reason, 'The owner wants to read a few more runs first.');++ assert.equal(e.s.lane_changes.at(-1).to_mode, 'observe'); assert.equal(e.s.lane_changes.at(-1).actor, 'owner');++});++++test('failed gate: an unavailable assessment in auto drops every auto lane back to observe with the reason', () => {++ const e = engine(); e.promote({ lane: 'fix' });++ e.receive(trigger()); const ev = e.start({ thread: 'room' });++ const out = e.decide({ evaluation: ev.id, decision: 'unavailable', reason: 'The assessment model could not return a usable answer.' });++ assert.equal(out.decision.decision, 'unavailable');++ assert.equal(e.s.policy.rules[0].mode, 'observe');++ assert.match(e.s.policy.rules[0].demote_reason, /The gate could not decide: The assessment model could not return a usable answer/);++ assert.deepEqual(e.s.observe[0].demoted_rules, ['fix']);++ assert.equal(e.s.lane_changes.at(-1).actor, 'system');++});++++test('spent budget: a report that uses the mandate budget puts the lane back in observe, in words', () => {++ const e = engine(); e.promote({ lane: 'fix' });++ const { decision } = assessOnce(e); const k = decision.ticket;++ e.report({ ticket_id: k.id, report: report(k, 'progress'), spent_usd: 0.4, task_id: 'tsk_1' });++ assert.equal(e.s.meta[k.id].spent_usd, 0.4); assert.equal(e.s.policy.rules[0].mode, 'auto');++ e.report({ ticket_id: k.id, report: report(k, 'progress'), spent_usd: 0.6, task_id: 'tsk_2' });++ assert.equal(e.s.meta[k.id].spent_usd, 1); assert.equal(e.s.policy.rules[0].mode, 'observe');++ assert.match(e.s.policy.rules[0].demote_reason, /used its whole budget of \$1\.00/);++ assert.match(e.s.meta[k.id].budget_stopped.reason, /\$1\.00/);++ // A retried task end does not count its spend twice.++ e.report({ ticket_id: k.id, report: report(k, 'progress'), spent_usd: 0.6, task_id: 'tsk_2' });++ assert.equal(e.s.meta[k.id].spent_usd, 1);++});++++test('gate record: a message no gate admits is still a readable verdict with nothing to run', () => {++ const e = new Engine(initial(), { now: 1000 });++ const c = cfg(); c.policy.sources.chat.allowed = ['alice']; c.policy.sources.chat.admission.room = { unlisted_unaddressed: 'ignore' };++ e.configure(c); e.receive(trigger('a', { actor: { source: 'chat', id: 'stranger', kind: 'person' } }));++ const ev = e.start({ thread: 'room' }); assert.equal(ev, null);++ const o = e.s.observe.at(-1);++ assert.deepEqual(o.gate, { admitted: false, strength: 'inferred' }); assert.equal(o.would_accept, false);++ assert.match(o.would_do.summary, /nothing would run/); assert.deepEqual(o.estimate, { low_usd: 0, high_usd: 0, basis: 'nothing would run' });++});++++test('report states: the six values and their stages are data, and every count starts at zero', () => {++ assert.deepEqual(REPORT_STATE_VALUES, ['progress', 'waiting', 'ready', 'blocked', 'failed', 'dropped']);++ assert.equal(REPORT_STATES.length, 6);++ for (const s of REPORT_STATES) { assert(s.label); assert(s.meaning); assert(REPORT_STAGES[s.state]); }++ assert.deepEqual(reportStates().map(s => s.state), REPORT_STATE_VALUES);++ assert.deepEqual(emptyReportCounts(), { progress: 0, waiting: 0, ready: 0, blocked: 0, failed: 0, dropped: 0 });++});++++console.log(`${count} fabric observe tests passed`);@@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');const { Engine, initial } = require('../server/fabric/engine');const { gate, policy } = require('../server/fabric/policy');const { translate, receiveEvent, schedule, tick } = require('../server/fabric/sources');--const cfg = () => ({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['room'], receive_all: ['room'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }], sources: { chat: { allowed: ['alice'], bots: ['ci'], admission: { room: { unlisted_addressed: 'inferred', unlisted_unaddressed: 'inferred' } } } }, budget: { usd: 1, tokens: 1000, wall_minutes: 10 } } });++const cfg = () => ({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['room'], receive_all: ['room'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects', mode: 'auto' }], sources: { chat: { allowed: ['alice'], bots: ['ci'], admission: { room: { unlisted_addressed: 'inferred', unlisted_unaddressed: 'inferred' } } } }, budget: { usd: 1, tokens: 1000, wall_minutes: 10 } } });const trigger = (id, extra = {}) => ({ id, source: 'chat', actor: { source: 'chat', id: 'alice', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'mention', ...extra });const good = () => ({ decision: 'accept', scope: 'Fix the broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Requested defect fix' }] });const engine = () => { const e = new Engine(initial(), { now: 1000 }); e.configure(cfg()); return e; };
@@ -12,17 +12,17 @@ An owner uses their `actor.agent.manage` credential:
@@ -12,17 +12,17 @@ An owner uses their `actor.agent.manage` credential:
- `GET /api/v1/fabric/:agent` reads state and revision.
- `GET /api/v1/fabric/:agent` reads state and revision.
- `POST /api/v1/fabric/:agent/commands/configure` stores `{sources, policy}`.
- `POST /api/v1/fabric/:agent/commands/configure` stores `{sources, policy}`.
-- Commands `start`, `decide`, `attach`, `stop`, `end`, `subscribe`, `unsubscribe`, `handBack`, `read`, `reply`, `schedule`, `cancelSchedule`, `tick`, `claim`, `settle`, `delivered` expose the engine ports. Unknown commands and foreign agents are refused. Source intake is not a public command.
+- Commands `start`, `decide`, `attach`, `stop`, `end`, `subscribe`, `unsubscribe`, `handBack`, `read`, `reply`, `schedule`, `cancelSchedule`, `tick`, `claim`, `settle`, `delivered`, `bind`, `report`, `promote`, `demote` expose the engine ports. Unknown commands and foreign agents are refused. Source intake is not a public command.
Source configuration maps instance ids to `{kind, enabled, self, scopes, receive_all}`. Chat additionally takes `mentions` (names without @); Mail takes `mail_agent`. The service stamps `owner_subject` from the authenticated owner, ignoring caller-supplied identities. Scope names are `global`, `channel:<id>`, `dm:<id>`, `inbox:<id>`, or configured time scope names. DMs must include both the bound agent identity and the owner. Mail events must name the owner's configured inbox and agent. Mail reads the original through the existing authorized Mail read client before entering the transaction, checks the server-stamped owner, inbox and sender, and groups replies by the References root. Chat room reply chains form Threads; separate top-level mentions do not share ownership. Own messages and events outside scope are dropped before domain intake. DM Events contain identifiers rather than text: the Trigger preserves these facts with a read pointer; workers must read the original through their authorized Chat connection.
Source configuration maps instance ids to `{kind, enabled, self, scopes, receive_all}`. Chat additionally takes `mentions` (names without @); Mail takes `mail_agent`. The service stamps `owner_subject` from the authenticated owner, ignoring caller-supplied identities. Scope names are `global`, `channel:<id>`, `dm:<id>`, `inbox:<id>`, or configured time scope names. DMs must include both the bound agent identity and the owner. Mail events must name the owner's configured inbox and agent. Mail reads the original through the existing authorized Mail read client before entering the transaction, checks the server-stamped owner, inbox and sender, and groups replies by the References root. Chat room reply chains form Threads; separate top-level mentions do not share ownership. Own messages and events outside scope are dropped before domain intake. DM Events contain identifiers rather than text: the Trigger preserves these facts with a read pointer; workers must read the original through their authorized Chat connection.
-Policies contain `mode` (`off|observe|auto`), `rules` (`id`, `enabled`, `instruction`), `budget` (`usd`, `tokens`, `wall_minutes`) and `sources`. Each Source policy has `allowed`, `bots` and an `admission` table keyed by scope, with `listed_unaddressed`, `unlisted_addressed` and `unlisted_unaddressed` cells. The first and last allow `inferred|ignore`; unlisted addressed also allows `explicit`. No identities are mapped between Sources.
+Policies contain `mode` (`off|observe|auto`), `rules` (`id`, `enabled`, `instruction`, optional `mode` `observe|auto`), `budget` (`usd`, `tokens`, `wall_minutes`) and `sources`. A rule without a mode is in observe: a lane never inherits autonomy from a global switch by accident. Each Source policy has `allowed`, `bots` and an `admission` table keyed by scope, with `listed_unaddressed`, `unlisted_addressed` and `unlisted_unaddressed` cells. The first and last allow `inferred|ignore`; unlisted addressed also allows `explicit`. No identities are mapped between Sources.
## Routing and workers
## Routing and workers
A `start` command batches pending Triggers on one Thread. While that evaluation runs, new events form its next batch. Explicit candidates produce `route` work; inferred candidates produce `assess` work. The router only routes; it cannot replace a worker-owned follow-up with new work. It may stop a Ticket on an explicitly authorized person's message.
A `start` command batches pending Triggers on one Thread. While that evaluation runs, new events form its next batch. Explicit candidates produce `route` work; inferred candidates produce `assess` work. The router only routes; it cannot replace a worker-owned follow-up with new work. It may stop a Ticket on an explicitly authorized person's message.
-The assessor returns `decision`, `scope`, `direction` and `rule_assessments`. Direction contains `implementation_requested`, `target_basis` (`specified|existing-standard|undetermined`), `requires_design_decision` and `evidence` entries `{trigger_id, quote}`. Every quote must occur in the original Trigger and every enabled rule must be assessed exactly once. Inferred acceptance needs settled direction and a matched rule. Observe records `ignore` with `would_accept`, with no launch or reply. The `ignored_from_card` shortcut can only ignore, with a reason. A missing/failed assessment should return `unavailable`; delivery starts the timeout, and three delivered expiries end unavailable after two retries.
+The assessor returns `decision`, `scope`, `direction` and `rule_assessments`. Direction contains `implementation_requested`, `target_basis` (`specified|existing-standard|undetermined`), `requires_design_decision` and `evidence` entries `{trigger_id, quote}`. Every quote must occur in the original Trigger and every enabled rule must be assessed exactly once. Inferred acceptance needs settled direction and a matched rule, and every matched lane in `auto`. Observe records `ignore` with `would_accept`, with no launch or reply. The `ignored_from_card` shortcut can only ignore, with a reason. A missing/failed assessment should return `unavailable`; delivery starts the timeout, and three delivered expiries end unavailable after two retries.
Acceptance creates an immutable Mandate and `launch` work. Subscriptions deliver later events directly through `input` work without changing authority. Human own-thread messages stay with their workers until all owners hand them back, once each with a reason. `context` subscriptions neither own messages nor resume ended Tickets. `attach` requires the message to explicitly name the Ticket. A worker cannot unsubscribe from origin or Source-bound Threads. `read` is called only after a step consumed the input and a report/turn end followed; ending releases unread messages. Full session-level read accounting and report/stage integration are S6.
Acceptance creates an immutable Mandate and `launch` work. Subscriptions deliver later events directly through `input` work without changing authority. Human own-thread messages stay with their workers until all owners hand them back, once each with a reason. `context` subscriptions neither own messages nor resume ended Tickets. `attach` requires the message to explicitly name the Ticket. A worker cannot unsubscribe from origin or Source-bound Threads. `read` is called only after a step consumed the input and a report/turn end followed; ending releases unread messages. Full session-level read accounting and report/stage integration are S6.
@@ -43,6 +43,87 @@ The task-to-Ticket binding is written in the task's own insert transaction (`act
@@ -43,6 +43,87 @@ The task-to-Ticket binding is written in the task's own insert transaction (`act
Each adapter must honor the stable idempotency key. Transport is at-least-once; a crash after sending and before settlement retries the same key. Expiring leases recover interrupted sends; stale lease tokens cannot settle newer claims. Retries start at 2 seconds and double to 60 seconds (replies: 5 to 300 seconds). Earlier work blocks later work in the same conversation. Other conversations proceed independently. Replies wait while their Source is off and drop only when its destination is no longer listened to or cannot receive. Drops retain their reason and time and cannot be requeued with the same key. Resume launches include a recovery instruction to inspect existing work before repeating outside actions.
Each adapter must honor the stable idempotency key. Transport is at-least-once; a crash after sending and before settlement retries the same key. Expiring leases recover interrupted sends; stale lease tokens cannot settle newer claims. Retries start at 2 seconds and double to 60 seconds (replies: 5 to 300 seconds). Earlier work blocks later work in the same conversation. Other conversations proceed independently. Replies wait while their Source is off and drop only when its destination is no longer listened to or cannot receive. Drops retain their reason and time and cannot be requeued with the same key. Resume launches include a recovery instruction to inspect existing work before repeating outside actions.
+## Observe then auto (S5)
+
+A lane is one entry in `policy.rules`. Its mode is `observe` or `auto`; a rule with no mode is in observe. The top-level
+`mode` is the master switch: `off` admits no inferred work, `observe` holds every lane in observe, and `auto` lets each
+lane follow its own mode. Explicit human asks are unaffected by lane modes. `promote` also sets the master to `auto`.
+
+In observe, an inferred evaluation is recorded in the aggregate (`state.observe`) and in `actor_fabric_observe` with the
+gate's verdict and basis, the assessment direction and per-rule reasons, what the lane would have done, and the
+estimated cost. Nothing is launched. The estimate is Actor's own data: the low and high USD cost of the agent's last
+finished runs (at least three), with basis `the last N finished runs of this agent`. Fewer than three is `null` with
+basis `no data yet`; a gate that admits nothing is 0 with `nothing would run`. The aggregate keeps the newest 500
+records; every row is mirrored to SQL in the same transaction that commits the aggregate.
+
+Every promotion and demotion is written to `actor_fabric_lane_changes` with the actor (`owner` or `system`) and the reason
+in words. Two things demote without the owner: a gate that cannot decide (`unavailable`) puts every auto lane back in
+observe with `The gate could not decide: <reason>.`; a task report that reaches the mandate's USD budget puts the
+Ticket's matched lanes back with `The job used its whole budget of $X.` Neither cancels work already running; the owner
+re-promotes after reading the records.
+
+### Endpoints for the panel
+
+All are owner-only (`actor.agent.manage`); a foreign agent is 404. `GET /api/v1/fabric/:agent/lanes`:
+
+```json
+{
+ "agent": "agt_01JZ0000000000000000000AAA",
+ "mode": "auto",
+ "policy_version": "8f1c…",
+ "budget": { "usd": 1, "tokens": 1000, "wall_minutes": 10 },
+ "lanes": [{
+ "rule_id": "fix",
+ "instruction": "Fix requested defects",
+ "enabled": true,
+ "mode": "observe",
+ "configured_mode": "observe",
+ "promoted_at": null,
+ "demoted_at": "2026-11-09T17:02:11.000Z",
+ "demote_reason": "The job used its whole budget of $1.00. This lane is in observe until you promote it again.",
+ "observe": { "count": 12, "last_at": "2026-11-09T17:01:58.000Z" },
+ "last_change": { "from_mode": "auto", "to_mode": "observe", "reason": "The job used its whole budget of $1.00. This lane is in observe until you promote it again.", "actor": "system", "at": "2026-11-09T17:02:11.000Z" },
+ "report_counts": { "progress": 3, "waiting": 0, "ready": 8, "blocked": 1, "failed": 1, "dropped": 0 }
+ }],
+ "report_states": [
+ { "state": "progress", "label": "Working", "meaning": "The executor is working on the Ticket." },
+ { "state": "waiting", "label": "Waiting", "meaning": "The executor is waiting for a trigger or an event." },
+ { "state": "ready", "label": "Ready for review", "meaning": "The work is done and ready for the owner to review." },
+ { "state": "blocked", "label": "Blocked", "meaning": "The executor cannot continue without a person." },
+ { "state": "failed", "label": "Failed", "meaning": "The last run failed." },
+ { "state": "dropped", "label": "Dropped", "meaning": "The work was stopped or dropped and will not continue." }
+ ]
+}
+```
+
+`GET /api/v1/fabric/:agent/observe?lane=fix&limit=20` (`lane` optional, `limit` 1–200, newest first):
+
+```json
+{
+ "agent": "agt_01JZ0000000000000000000AAA",
+ "lane": "fix",
+ "records": [{
+ "id": "observe_184", "seq": 184, "at": "2026-11-09T17:01:58.000Z", "thread": "thread_…",
+ "mode": "observe", "trigger_ids": ["trigger_…"], "rule_ids": ["fix"],
+ "gate": { "admitted": true, "strength": "inferred", "basis": "table", "decision": "accept" },
+ "decision": "ignore", "would_accept": true,
+ "scope": "Fix the broken test",
+ "would_do": { "action": "launch", "summary": "Would have started work: Fix the broken test" },
+ "estimate": { "low_usd": 0.02, "high_usd": 0.06, "basis": "the last 3 finished runs of this agent" },
+ "demoted_rules": [], "reason": "Observe: nothing was launched. Promote this lane to auto to let it act.",
+ "direction": { "implementation_requested": true, "target_basis": "specified", "requires_design_decision": false, "evidence": [{ "trigger_id": "trigger_…", "quote": "broken test" }] },
+ "rule_assessments": [{ "rule_id": "fix", "matched": true, "reason": "The message names a broken test" }]
+ }],
+ "report_states": ["the same six objects as above"]
+}
+```
+
+`GET /api/v1/fabric/:agent/report-states` returns `{agent, report_states}` (the six objects above) for a legend that loads before any ticket. `GET /api/v1/fabric/:agent/reports` returns `{agent, tickets: [{ticket_id, state, stage, lanes, threads, reports}], report_states}`; each report is the stored `agent.report@1` (`progress|waiting|ready|blocked|failed|dropped`). The first row of `agent.stage@1` that matches the latest report is the ticket's `stage`.
+
+`POST /api/v1/fabric/:agent/commands/promote` with `{"lane":"fix"}` → `{"lane":"fix","mode":"auto","policy_version":"…","changed":true}`. The panel re-reads `/lanes` for the new `last_change`.
+
+`POST /api/v1/fabric/:agent/commands/demote` with `{"lane":"fix","reason":"Reading a few more runs first."}`, or `{"reason":"…"}` to demote every lane and the master switch → `{"lane":"fix","mode":"observe","policy_version":"…","changes":[{"rule_id":"fix","from_mode":"auto","to_mode":"observe","reason":"…","actor":"owner"}]}`.
+
Time belongs to `sources.js`, not assessment. A schedule is `{id, source, scope, due, text, interval_ms?}`; timestamps are epoch milliseconds. A quiet condition adds `{quiet_thread, since_trigger}`. The Source cancels it if that Thread changes before its due time. Time produces immutable system Triggers; admission requires `clock` in the Source's bot list and the scope in `receive_all`. Time never grants explicit authority. `tick` checks due conditions, expires evaluations and starts pending batches; it never sleeps or calls a model.
Time belongs to `sources.js`, not assessment. A schedule is `{id, source, scope, due, text, interval_ms?}`; timestamps are epoch milliseconds. A quiet condition adds `{quiet_thread, since_trigger}`. The Source cancels it if that Thread changes before its due time. Time produces immutable system Triggers; admission requires `clock` in the Source's bot list and the scope in `receive_all`. Time never grants explicit authority. `tick` checks due conditions, expires evaluations and starts pending batches; it never sleeps or calls a model.
-Account export/deletion includes the fabric aggregate and the task-to-Ticket binding. The expand-only migrations add no production writes during development. Tests name the corresponding rules from reference docs 001–003; `ov test test/fabric.test.js test/fabric-store.test.js test/fabric-adapters.test.js test/fabric-executors.test.js` exercises policy, intake, persistence, ownership, routing, delivery, adapters and one real run on the stand-in providers without external services.
+Account export/deletion includes the fabric aggregate, the task-to-Ticket binding, the observe records and the lane changes. The expand-only migrations add no production writes during development. Tests name the corresponding rules from reference docs 001–003; `ov test test/fabric.test.js test/fabric-store.test.js test/fabric-adapters.test.js test/fabric-executors.test.js test/fabric-observe.test.js test/fabric-observe-store.test.js` exercises policy, intake, persistence, ownership, routing, delivery, adapters, observe records, promotion/demotion, the six report states and one real run on the stand-in providers without external services.
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+-- phase: expand
+-- S5 observe records: an append-only read model of what a lane would have done (the gate's verdict and the estimated
+-- cost), plus every promotion/demotion of a lane with its reason. The agent's JSONB aggregate remains the transaction
+-- boundary: service.js mirrors new records into these tables in the same transaction that commits the aggregate.
+CREATE TABLE actor_fabric_observe (
+ id TEXT PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES actor_agents(id) ON DELETE CASCADE,
+ owner TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ thread TEXT,
+ rule_ids TEXT[] NOT NULL DEFAULT '{}',
+ record JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX actor_fabric_observe_agent ON actor_fabric_observe(agent_id, seq DESC);
+CREATE INDEX actor_fabric_observe_rules ON actor_fabric_observe USING gin (rule_ids);
+
+CREATE TABLE actor_fabric_lane_changes (
+ id TEXT PRIMARY KEY,
+ agent_id TEXT NOT NULL REFERENCES actor_agents(id) ON DELETE CASCADE,
+ owner TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ rule_id TEXT NOT NULL,
+ from_mode TEXT NOT NULL,
+ to_mode TEXT NOT NULL,
+ reason TEXT NOT NULL,
+ actor TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX actor_fabric_lane_changes_agent ON actor_fabric_lane_changes(agent_id, seq DESC);
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_spends', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases', 'actor_mention_firings', 'actor_fabric_tasks'];
+const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_approval_allows', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state', 'actor_memory_tidy', 'actor_memory_tidy_runs', 'actor_memory_changes', 'actor_memory_diary', 'actor_spends', 'actor_skill_listings', 'actor_skill_installs', 'actor_canvases', 'actor_mention_firings', 'actor_fabric_tasks', 'actor_fabric_observe', 'actor_fabric_lane_changes'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -273,7 +273,7 @@ function createFabricAdapters({ s, config, fabric, models, ctx: taskCtx = null,
@@ -273,7 +273,7 @@ function createFabricAdapters({ s, config, fabric, models, ctx: taskCtx = null,
const ticket = state.tickets[ticketId];
const ticket = state.tickets[ticketId];
if (ticket.state !== 'open') return;
if (ticket.state !== 'open') return;
const report = reportFor(row, ticket);
const report = reportFor(row, ticket);
- await fabric.command(owner, agentId, 'report', { ticket_id: ticketId, report, end: true });
+ await fabric.command(owner, agentId, 'report', { ticket_id: ticketId, report, end: true, spent_usd: Number(row.cost_usd) || 0, task_id: row.id });
// One confirmation to the person on the Source thread, not a stream of progress.
// One confirmation to the person on the Source thread, not a stream of progress.
const source = state.sources[ticket.mandate.actor.source];
const source = state.sources[ticket.mandate.actor.source];
if (source) {
if (source) {
@@ -2,6 +2,8 @@
@@ -2,6 +2,8 @@
const express = require('express');
const express = require('express');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+const { reportStates, emptyReportCounts } = require('./reports');
+const { laneMode } = require('./policy');
function createRoutes(ctx) {
function createRoutes(ctx) {
const r = asyncRouter();
const r = asyncRouter();
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
@@ -17,6 +19,73 @@ function createRoutes(ctx) {
@@ -17,6 +19,73 @@ function createRoutes(ctx) {
catch (err) { if (err.status) return contracts.http.sendProblem(res, err.status, 'actor.fabric.refused', { detail: err.message, ctx: req.ov }); next(err); }
catch (err) { if (err.status) return contracts.http.sendProblem(res, err.status, 'actor.fabric.refused', { detail: err.message, ctx: req.ov }); next(err); }
};
};
r.get('/:agent', run(req => ctx.fabric.snapshot(req.principal.requester, req.params.agent)));
r.get('/:agent', run(req => ctx.fabric.snapshot(req.principal.requester, req.params.agent)));
+
+ /** What the panel needs for a trigger's mode: each lane, its observe count and its reports by state. */
+ r.get('/:agent/lanes', run(async (req) => {
+ const owner = req.principal.requester, agent = req.params.agent;
+ const snap = await ctx.fabric.snapshot(owner, agent);
+ const state = snap.state;
+ const stats = await ctx.fabric.laneStats(owner, agent);
+ const seen = new Map(stats.observe.map((x) => [x.rule_id, x]));
+ const changed = new Map(stats.changes.map((x) => [x.rule_id, x]));
+ const reports = new Map();
+ for (const [ticketId, ticket] of Object.entries(state.tickets || {})) {
+ const meta = (state.meta || {})[ticketId] || {};
+ for (const report of meta.reports || []) {
+ for (const lane of (ticket.mandate && ticket.mandate.matched_rules) || []) {
+ if (!reports.has(lane)) reports.set(lane, emptyReportCounts());
+ if (Object.hasOwn(reports.get(lane), report.state)) reports.get(lane)[report.state] += 1;
+ }
+ }
+ }
+ const lanes = (state.policy && state.policy.rules ? state.policy.rules : []).map((rule) => {
+ const last = changed.get(rule.id);
+ return {
+ rule_id: rule.id,
+ instruction: rule.instruction,
+ enabled: rule.enabled !== false,
+ mode: state.policy.mode === 'auto' ? laneMode(rule) : 'observe',
+ configured_mode: laneMode(rule),
+ promoted_at: rule.promoted_at || null,
+ demoted_at: rule.demoted_at || null,
+ demote_reason: rule.demote_reason || null,
+ observe: { count: Number((seen.get(rule.id) || {}).count) || 0, last_at: (seen.get(rule.id) || {}).last_at || null },
+ last_change: last ? { from_mode: last.from_mode, to_mode: last.to_mode, reason: last.reason, actor: last.actor, at: last.created_at } : null,
+ report_counts: reports.get(rule.id) || emptyReportCounts(),
+ };
+ });
+ return {
+ agent, mode: state.policy ? state.policy.mode : 'observe', policy_version: state.policy ? state.policy.version : null,
+ budget: state.policy ? state.policy.budget : null, lanes, report_states: reportStates(),
+ };
+ }));
+
+ /** What the panel needs for observe records: newest first, one lane or all of them. */
+ r.get('/:agent/observe', run(async (req) => {
+ const lane = req.query.lane == null || req.query.lane === '' ? null : String(req.query.lane).slice(0, 80);
+ const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 50));
+ const records = await ctx.fabric.observe(req.principal.requester, req.params.agent, { lane, limit });
+ return { agent: req.params.agent, lane, records, report_states: reportStates() };
+ }));
+
+ /** The six report states alone, for a panel that shows the legend before it loads a ticket. */
+ r.get('/:agent/report-states', run(req => ({ agent: req.params.agent, report_states: reportStates() })));
+
+ /** The reports behind the six states, ticket by ticket. */
+ r.get('/:agent/reports', run(async (req) => {
+ const snap = await ctx.fabric.snapshot(req.principal.requester, req.params.agent);
+ const state = snap.state;
+ const tickets = Object.entries(state.tickets || {}).map(([id, ticket]) => ({
+ ticket_id: id,
+ state: ticket.state,
+ stage: ticket.stage,
+ lanes: (ticket.mandate && ticket.mandate.matched_rules) || [],
+ threads: ticket.threads || [],
+ reports: ((state.meta || {})[id] || {}).reports || [],
+ }));
+ return { agent: req.params.agent, tickets, report_states: reportStates() };
+ }));
+
r.post('/:agent/commands/:command', run(req => ctx.fabric.command(req.principal.requester, req.params.agent, req.params.command, req.body)));
r.post('/:agent/commands/:command', run(req => ctx.fabric.command(req.principal.requester, req.params.agent, req.params.command, req.body)));
return r;
return r;
}
}
@@ -1,18 +1,99 @@
@@ -1,18 +1,99 @@
'use strict';
'use strict';
-const { policy, gate, revalidate, assessment, assert, text } = require('./policy');
+const { policy, gate, revalidate, assessment, assert, text, laneMode } = require('./policy');
+const { REPORT_STAGES } = require('./reports');
const copy = structuredClone;
const copy = structuredClone;
const own = (map, key) => Object.hasOwn(map, key) ? map[key] : undefined;
const own = (map, key) => Object.hasOwn(map, key) ? map[key] : undefined;
const emptyDirection = () => ({ implementation_requested: false, target_basis: 'undetermined', requires_design_decision: false, evidence: [] });
const emptyDirection = () => ({ implementation_requested: false, target_basis: 'undetermined', requires_design_decision: false, evidence: [] });
-// The six agent.report@1 states and the stage each one leaves on an open Ticket (agent.stage@1, first match).
-const REPORT_STAGES = Object.freeze({ ready: 'person/review', blocked: 'person/blocked', failed: 'person/failed', waiting: 'idle', progress: 'working', dropped: 'idle' });
+const NO_ESTIMATE = Object.freeze({ low_usd: null, high_usd: null, basis: 'no data yet' });
+const NOTHING_RUNS = Object.freeze({ low_usd: 0, high_usd: 0, basis: 'nothing would run' });
+const round = (n) => Math.round((Number(n) || 0) * 1e6) / 1e6;
+const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
function initial() {
function initial() {
- return { policy: policy(), sources: {}, triggers: {}, threads: {}, tickets: {}, meta: {}, decisions: [], work: [], schedules: {}, accepted: {}, seq: 0 };
+ return { policy: policy(), sources: {}, triggers: {}, threads: {}, tickets: {}, meta: {}, decisions: [], work: [], schedules: {}, accepted: {}, seq: 0,
+ // S5: what each lane would have done, and every promotion/demotion with its reason (mirrored to SQL by service.js).
+ observe: [], observe_seq: 0, lane_changes: [], lane_seq: 0 };
}
}
// All methods are synchronous. The repository loads and locks an agent's aggregate, invokes exactly one command,
// All methods are synchronous. The repository loads and locks an agent's aggregate, invokes exactly one command,
// then commits its state and outbox together. No model, timer, promise or network port is reachable here.
// then commits its state and outbox together. No model, timer, promise or network port is reachable here.
class Engine {
class Engine {
- constructor(state, { now = Date.now(), validate = () => {} } = {}) { this.s = state; this.now = now; this.validate = validate; }
+ constructor(state, { now = Date.now(), validate = () => {}, estimate = null } = {}) {
+ this.s = state; this.now = now; this.validate = validate;
+ this.estimate = typeof estimate === 'function' ? estimate : () => NO_ESTIMATE;
+ // Rows written before S5 have no observe state; they gain it on their next command.
+ this.s.observe ||= []; this.s.observe_seq ||= 0; this.s.lane_changes ||= []; this.s.lane_seq ||= 0;
+ }
id(kind) { return `${kind}_${++this.s.seq}`; }
id(kind) { return `${kind}_${++this.s.seq}`; }
+ iso() { return new Date(this.now).toISOString(); }
+ lane(id) { return this.s.policy.rules.find(r => r.id === id) || null; }
+ laneModeOf(id) { return laneMode(this.lane(id)); }
+ /** The cost range the estimate port gives for this lane, or the honest "no data yet". */
+ estimateFor(lanes, triggers, scope) {
+ try {
+ const out = this.estimate({ lanes, triggers, scope, policy: this.s.policy });
+ return out && typeof out === 'object' ? { low_usd: out.low_usd == null ? null : round(out.low_usd), high_usd: out.high_usd == null ? null : round(out.high_usd), basis: String(out.basis || 'no data yet') } : { ...NO_ESTIMATE };
+ } catch { return { ...NO_ESTIMATE }; }
+ }
+ /** One observe record. The aggregate keeps the newest 500; service.js mirrors every row to SQL in the transaction. */
+ recordObserve(entry) {
+ const seq = ++this.s.observe_seq;
+ const record = { id: `observe_${seq}`, seq, at: this.iso(), ...entry };
+ this.s.observe.push(record);
+ if (this.s.observe.length > 500) this.s.observe.splice(0, this.s.observe.length - 500);
+ return record;
+ }
+ recordLaneChange(change) {
+ const seq = ++this.s.lane_seq;
+ this.s.lane_changes.push({ id: `lane_${seq}`, seq, at: this.iso(), ...change });
+ if (this.s.lane_changes.length > 200) this.s.lane_changes.splice(0, this.s.lane_changes.length - 200);
+ }
+ /** Take lanes out of auto with one policy version change; every lane that actually moved gets an audit row. */
+ demoteRules(ids, reason, actor = 'system') {
+ const why = text(reason) ? String(reason).trim() : 'This lane is back in observe.';
+ const wanted = [...new Set(ids || [])].filter(id => this.lane(id));
+ const changes = [];
+ if (!wanted.length) return changes;
+ const next = copy(this.s.policy);
+ for (const r of next.rules) {
+ if (!wanted.includes(r.id) || laneMode(r) === 'observe') continue;
+ r.mode = 'observe'; r.demoted_at = this.iso(); r.demote_reason = why; r.demoted_by = actor;
+ changes.push({ rule_id: r.id, from_mode: 'auto', to_mode: 'observe', reason: why, actor });
+ }
+ if (!changes.length) return changes;
+ if (!next.rules.some(r => laneMode(r) === 'auto')) next.mode = 'observe';
+ this.configure({ policy: next });
+ for (const c of changes) this.recordLaneChange(c);
+ return changes;
+ }
+ /** The owner moves one lane to auto after reading its observe records. */
+ promote({ lane }) {
+ assert(text(lane), 'Name the lane to promote');
+ const rule = this.lane(lane);
+ assert(rule, 'Unknown lane');
+ if (laneMode(rule) === 'auto') return { lane, mode: 'auto', policy_version: this.s.policy.version, changed: false };
+ const next = copy(this.s.policy);
+ next.mode = 'auto';
+ const target = next.rules.find(r => r.id === lane);
+ target.mode = 'auto'; target.promoted_at = this.iso(); target.promoted_by = 'owner';
+ delete target.demoted_at; delete target.demote_reason; delete target.demoted_by;
+ this.configure({ policy: next });
+ this.recordLaneChange({ rule_id: lane, from_mode: 'observe', to_mode: 'auto', reason: 'The owner promoted this lane after reviewing its observe records.', actor: 'owner' });
+ return { lane, mode: 'auto', policy_version: this.s.policy.version, changed: true };
+ }
+ /** The owner moves one lane (or the whole agent) back to observe; the reason is said in words. */
+ demote({ lane = null, reason = null }) {
+ assert(lane == null || text(lane), 'Name a lane or demote the whole agent');
+ assert(reason == null || text(reason), 'Say why in words');
+ if (lane != null) {
+ assert(this.lane(lane), 'Unknown lane');
+ const changes = this.demoteRules([lane], reason || 'The owner switched this lane back to observe.', 'owner');
+ return { lane, mode: this.laneModeOf(lane), policy_version: this.s.policy.version, changes };
+ }
+ const changes = this.demoteRules(this.s.policy.rules.map(r => r.id), reason || 'The owner switched the agent back to observe.', 'owner');
+ if (this.s.policy.mode !== 'observe') {
+ const next = copy(this.s.policy); next.mode = 'observe'; this.configure({ policy: next });
+ }
+ return { lane: null, mode: 'observe', policy_version: this.s.policy.version, changes };
+ }
thread(key) { assert(text(key) && !['__proto__', 'constructor', 'prototype'].includes(key), 'Invalid Thread'); return this.s.threads[key] ||= { pending: [], subscriptions: {}, evaluation: null, history: [] }; }
thread(key) { assert(text(key) && !['__proto__', 'constructor', 'prototype'].includes(key), 'Invalid Thread'); return this.s.threads[key] ||= { pending: [], subscriptions: {}, evaluation: null, history: [] }; }
queue(t) {
queue(t) {
const th = this.thread(t.threads[0]);
const th = this.thread(t.threads[0]);
@@ -100,7 +181,18 @@ class Engine {
@@ -100,7 +181,18 @@ class Engine {
const best = candidates[0];
const best = candidates[0];
th.pending = [];
th.pending = [];
th.evaluation = { id: this.id('eval'), ids, primary: best?.id || ids[0], strength: best?.result.strength || 'inferred', basis: best?.result.basis, policy_version: this.s.policy.version, attempts: th.expiries || 0 };
th.evaluation = { id: this.id('eval'), ids, primary: best?.id || ids[0], strength: best?.result.strength || 'inferred', basis: best?.result.basis, policy_version: this.s.policy.version, attempts: th.expiries || 0 };
- if (!best) { this.finish(th, 'ignore', { reason: 'not_admitted' }); return null; }
+ if (!best) {
+ this.finish(th, 'ignore', { reason: 'not_admitted' });
+ // A gate that admits nothing is still a verdict the owner can read. It has no lane (first match decided
+ // nothing), so the record belongs to the Thread and says what would have happened: nothing.
+ if (this.s.policy.mode !== 'off') this.recordObserve({
+ thread, mode: 'observe', trigger_ids: ids, rule_ids: [], gate: { admitted: false, strength: 'inferred' },
+ decision: 'ignore', would_accept: false, scope: null,
+ would_do: { action: 'ignore', summary: 'The gate did not admit this message, so nothing would run.' },
+ estimate: { ...NOTHING_RUNS }, reason: 'Not admitted by the deterministic gate.',
+ });
+ return null;
+ }
const e = copy(th.evaluation);
const e = copy(th.evaluation);
// Explicit candidates go to the deterministic routing port, never the assessor.
// Explicit candidates go to the deterministic routing port, never the assessor.
this.enqueue(e.strength === 'explicit' ? 'route' : 'assess', `candidate:${e.id}`, { evaluation: e, triggers: ids.map(id => this.s.triggers[id]), tickets: Object.values(this.s.tickets).filter(k => k.threads.includes(thread)), policy: this.s.policy, handbacks: Object.values(this.s.meta).flatMap(m => m.handbacks).filter(h => ids.includes(h.trigger_id)) }, `router:${thread}`);
this.enqueue(e.strength === 'explicit' ? 'route' : 'assess', `candidate:${e.id}`, { evaluation: e, triggers: ids.map(id => this.s.triggers[id]), tickets: Object.values(this.s.tickets).filter(k => k.threads.includes(thread)), policy: this.s.policy, handbacks: Object.values(this.s.meta).flatMap(m => m.handbacks).filter(h => ids.includes(h.trigger_id)) }, `router:${thread}`);
@@ -131,7 +223,45 @@ class Engine {
@@ -131,7 +223,45 @@ class Engine {
assert(e.strength === 'inferred' && decision === 'ignore' && text(reason), 'Card shortcut only ignores inferred work with a reason');
assert(e.strength === 'inferred' && decision === 'ignore' && text(reason), 'Card shortcut only ignores inferred work with a reason');
audit.ignored_from_card = true;
audit.ignored_from_card = true;
} else assert(!a, 'Explicit candidates do not use assessment');
} else assert(!a, 'Explicit candidates do not use assessment');
- if (decision === 'accept' && e.strength === 'inferred' && this.s.policy.mode !== 'auto') { decision = 'ignore'; audit.would_accept = true; }
+ const lanes = e.strength === 'inferred' ? matched.map(id => this.lane(id)).filter(Boolean) : [];
+ const anyObserve = lanes.some(r => laneMode(r) !== 'auto');
+ const preDecision = decision;
+ if (decision === 'accept' && e.strength === 'inferred' && (this.s.policy.mode !== 'auto' || anyObserve)) {
+ decision = 'ignore'; audit.would_accept = true;
+ }
+ let observe = null;
+ if (e.strength === 'inferred') {
+ const observing = decision === 'ignore' && audit.would_accept === true;
+ const accepted = decision === 'accept';
+ const scopeText = text(scope) ? scope : null;
+ // A gate that cannot decide must not keep an auto lane running on its own judgement. The demotion runs
+ // after this evaluation is finished: a policy change discards live evaluations by design.
+ const demote = decision === 'unavailable'
+ ? { ids: this.s.policy.rules.filter(r => laneMode(r) === 'auto').map(r => r.id), reason: `The gate could not decide: ${clip(audit.reason || 'the assessment was unavailable', 200)}. This lane is in observe until you promote it again.` }
+ : null;
+ const estimate = observing || accepted
+ ? this.estimateFor(lanes, e.ids.map(id => this.s.triggers[id]), scopeText)
+ : { ...NOTHING_RUNS };
+ const why = observing ? 'Observe: nothing was launched. Promote this lane to auto to let it act.'
+ : accepted ? 'Auto: the work was launched.'
+ : decision === 'unavailable' ? clip(audit.reason || 'The gate could not decide, so nothing ran.', 400)
+ : clip(audit.reason || 'No enabled rule matched, so nothing would run.', 400);
+ observe = this.recordObserve({
+ thread: Object.keys(this.s.threads).find(key => this.s.threads[key] === th) || null,
+ mode: this.s.policy.mode === 'auto' && lanes.length && lanes.every(r => laneMode(r) === 'auto') ? 'auto' : 'observe',
+ trigger_ids: e.ids,
+ rule_ids: lanes.map(r => r.id),
+ gate: { admitted: true, strength: e.strength, basis: e.basis || null, decision: preDecision },
+ decision, would_accept: audit.would_accept === true,
+ scope: scopeText, direction: audit.direction || emptyDirection(),
+ rule_assessments: audit.rule_assessments || [],
+ would_do: observing ? { action: 'launch', summary: `Would have started work: ${clip(scopeText || 'the matched work', 300)}` }
+ : accepted ? { action: 'launch', summary: `Started work: ${clip(scopeText || 'the matched work', 300)}` }
+ : { action: 'ignore', summary: why },
+ estimate, demoted_rules: [], reason: why,
+ });
+ observe.demote = demote;
+ }
let ticket;
let ticket;
if (decision === 'accept') {
if (decision === 'accept') {
assert(text(scope), 'Acceptance needs a scope');
assert(text(scope), 'Acceptance needs a scope');
@@ -146,7 +276,12 @@ class Engine {
@@ -146,7 +276,12 @@ class Engine {
this.enqueue('launch', `launch:${ticket.id}`, { ticket, triggers: e.ids.map(id => this.s.triggers[id]) }, `worker:${ticket.id}`);
this.enqueue('launch', `launch:${ticket.id}`, { ticket, triggers: e.ids.map(id => this.s.triggers[id]) }, `worker:${ticket.id}`);
}
}
th.expiries = 0;
th.expiries = 0;
- return { decision: this.finish(th, decision, audit), ...(ticket ? { ticket } : {}) };
+ const finished = this.finish(th, decision, audit);
+ if (observe && observe.demote) {
+ observe.demoted_rules = this.demoteRules(observe.demote.ids, observe.demote.reason).map(c => c.rule_id);
+ delete observe.demote;
+ }
+ return { decision: finished, ...(ticket ? { ticket } : {}), ...(observe ? { observe } : {}) };
}
}
/** An executor port bound the Ticket to the task that runs it (the last run of a follow-up is the current one). */
/** An executor port bound the Ticket to the task that runs it (the last run of a follow-up is the current one). */
bind({ ticket_id, task_id }) {
bind({ ticket_id, task_id }) {
@@ -158,17 +293,31 @@ class Engine {
@@ -158,17 +293,31 @@ class Engine {
return k;
return k;
}
}
/** Record one agent.report@1 on the Ticket; a terminal report may end it. */
/** Record one agent.report@1 on the Ticket; a terminal report may end it. */
- report({ ticket_id, report, end = false }) {
+ report({ ticket_id, report, end = false, spent_usd = 0, task_id = null }) {
const k = own(this.s.tickets, ticket_id), m = own(this.s.meta, ticket_id);
const k = own(this.s.tickets, ticket_id), m = own(this.s.meta, ticket_id);
assert(k && m && report && typeof report === 'object' && !Array.isArray(report), 'Report needs an open Ticket');
assert(k && m && report && typeof report === 'object' && !Array.isArray(report), 'Report needs an open Ticket');
assert(k.state === 'open', 'Ticket has ended');
assert(k.state === 'open', 'Ticket has ended');
+ assert(Number.isFinite(Number(spent_usd)) && Number(spent_usd) >= 0, 'Invalid spend');
+ m.spent_tasks ||= [];
+ // A retried task end reports once: the spend stays the task's own, not a second copy.
+ if (task_id && m.spent_tasks.includes(task_id)) return { ticket: k, report: m.reports[m.reports.length - 1] || null, spent_usd: m.spent_usd, repeated: true };
+ if (task_id) m.spent_tasks.push(task_id);
const r = copy({ ...report, ticket_id });
const r = copy({ ...report, ticket_id });
this.validate('agent.report', r);
this.validate('agent.report', r);
m.reports ||= [];
m.reports ||= [];
m.reports.push(r);
m.reports.push(r);
+ m.spent_usd = round(Number(m.spent_usd || 0) + Number(spent_usd || 0));
if (REPORT_STAGES[r.state]) k.stage = REPORT_STAGES[r.state];
if (REPORT_STAGES[r.state]) k.stage = REPORT_STAGES[r.state];
+ // The mandate's budget is a cap, not advice: a spent budget puts the lane that produced the Ticket back in
+ // observe, with the number said in words, and the owner decides whether to promote it again.
+ const budget = Number(k.mandate && k.mandate.budget && k.mandate.budget.usd) || 0;
+ if (budget > 0 && m.spent_usd >= budget - 1e-9) {
+ const why = `The job used its whole budget of $${budget.toFixed(2)}. This lane is in observe until you promote it again.`;
+ m.budget_stopped = { at: this.iso(), reason: why, spent_usd: m.spent_usd };
+ this.demoteRules(k.mandate.matched_rules || [], why, 'system');
+ }
if (end) this.end({ ticket_id, reason: `report:${r.state}` });
if (end) this.end({ ticket_id, reason: `report:${r.state}` });
- return { ticket: k, report: r };
+ return { ticket: k, report: r, spent_usd: m.spent_usd };
}
}
subscribe({ ticket_id, thread, kind = 'context' }) {
subscribe({ ticket_id, thread, kind = 'context' }) {
const k = own(this.s.tickets, ticket_id);
const k = own(this.s.tickets, ticket_id);
@@ -6,7 +6,7 @@ function policy(input = {}) {
@@ -6,7 +6,7 @@ function policy(input = {}) {
const p = structuredClone({ mode: 'observe', rules: [], sources: {}, budget: { usd: 0, tokens: 0, wall_minutes: 0 }, ...input });
const p = structuredClone({ mode: 'observe', rules: [], sources: {}, budget: { usd: 0, tokens: 0, wall_minutes: 0 }, ...input });
delete p.version;
delete p.version;
assert(['off', 'observe', 'auto'].includes(p.mode), 'Invalid accept mode');
assert(['off', 'observe', 'auto'].includes(p.mode), 'Invalid accept mode');
- assert(Array.isArray(p.rules) && p.rules.every(r => text(r.id) && text(r.instruction) && typeof r.enabled === 'boolean') && new Set(p.rules.map(r => r.id)).size === p.rules.length, 'Rules need unique ids, instructions and enabled flags');
+ assert(Array.isArray(p.rules) && p.rules.every(r => text(r.id) && text(r.instruction) && typeof r.enabled === 'boolean' && (r.mode == null || ['observe', 'auto'].includes(r.mode))) && new Set(p.rules.map(r => r.id)).size === p.rules.length, 'Rules need unique ids, instructions, enabled flags and an observe or auto mode');
assert(p.budget && ['usd', 'tokens', 'wall_minutes'].every(k => Number.isFinite(p.budget[k]) && p.budget[k] >= 0) && Number.isInteger(p.budget.tokens), 'Invalid mandate budget');
assert(p.budget && ['usd', 'tokens', 'wall_minutes'].every(k => Number.isFinite(p.budget[k]) && p.budget[k] >= 0) && Number.isInteger(p.budget.tokens), 'Invalid mandate budget');
assert(p.sources && typeof p.sources === 'object' && !Array.isArray(p.sources), 'Invalid source policies');
assert(p.sources && typeof p.sources === 'object' && !Array.isArray(p.sources), 'Invalid source policies');
for (const s of Object.values(p.sources)) {
for (const s of Object.values(p.sources)) {
@@ -67,4 +67,6 @@ function assessment(a, p, triggers) {
@@ -67,4 +67,6 @@ function assessment(a, p, triggers) {
if (accepted) assert(text(a.scope), 'Acceptance needs a scope');
if (accepted) assert(text(a.scope), 'Acceptance needs a scope');
return { matched, accepted };
return { matched, accepted };
}
}
-module.exports = { policy, gate, revalidate, assessment, assert, text };
+// A lane without an explicit mode is in observe: lanes never inherit autonomy from a global switch by accident.
+const laneMode = (rule) => (rule && rule.mode === 'auto' ? 'auto' : 'observe');
+module.exports = { policy, gate, revalidate, assessment, assert, text, laneMode };
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+
+/**
+ * The six agent.report@1 states, as data for the panel (plan W1/W9, S5). A report is the executor's own account of a
+ * Ticket; a ticket stage is computed from the latest report by the first matching row of agent.stage@1. The states are
+ * closed: an executor that wants another word is asking for a contract change, not a new string.
+ *
+ * REPORT_STATES [{state, label, meaning}] in contract order
+ * REPORT_STATE_VALUES the six values alone (for checks and counts)
+ * REPORT_STAGES the stage each state leaves on an open Ticket
+ * reportStates() a copy for an API response
+ * emptyReportCounts() all six at zero, for a lane's per-state summary
+ */
+const REPORT_STATES = Object.freeze([
+ { state: 'progress', label: 'Working', meaning: 'The executor is working on the Ticket.' },
+ { state: 'waiting', label: 'Waiting', meaning: 'The executor is waiting for a trigger or an event.' },
+ { state: 'ready', label: 'Ready for review', meaning: 'The work is done and ready for the owner to review.' },
+ { state: 'blocked', label: 'Blocked', meaning: 'The executor cannot continue without a person.' },
+ { state: 'failed', label: 'Failed', meaning: 'The last run failed.' },
+ { state: 'dropped', label: 'Dropped', meaning: 'The work was stopped or dropped and will not continue.' },
+]);
+const REPORT_STATE_VALUES = Object.freeze(REPORT_STATES.map((s) => s.state));
+const REPORT_STAGES = Object.freeze({ ready: 'person/review', blocked: 'person/blocked', failed: 'person/failed', waiting: 'idle', progress: 'working', dropped: 'idle' });
+
+const reportStates = () => REPORT_STATES.map((s) => ({ ...s }));
+const emptyReportCounts = () => Object.fromEntries(REPORT_STATE_VALUES.map((s) => [s, 0]));
+
+module.exports = { REPORT_STATES, REPORT_STATE_VALUES, REPORT_STAGES, reportStates, emptyReportCounts };
@@ -3,13 +3,47 @@ const contracts = require('openvibe-contracts');
@@ -3,13 +3,47 @@ const contracts = require('openvibe-contracts');
const { Engine, initial } = require('./engine');
const { Engine, initial } = require('./engine');
const sources = require('./sources');
const sources = require('./sources');
const { assert } = require('./policy');
const { assert } = require('./policy');
-const COMMANDS = new Set(['configure', 'start', 'decide', 'subscribe', 'attach', 'unsubscribe', 'handBack', 'read', 'end', 'stop', 'reply', 'claim', 'settle', 'delivered', 'bind', 'report']);
+const COMMANDS = new Set(['configure', 'start', 'decide', 'subscribe', 'attach', 'unsubscribe', 'handBack', 'read', 'end', 'stop', 'reply', 'claim', 'settle', 'delivered', 'bind', 'report', 'promote', 'demote']);
+const round = (n) => Math.round((Number(n) || 0) * 1e6) / 1e6;
+// The first promotion review needs a little history; a single run is not a basis for an estimate.
+const ESTIMATE_RUNS = 3;
function createFabric({ s, enabled = false, readMail, validate = (name, value) => {
function createFabric({ s, enabled = false, readMail, validate = (name, value) => {
try { return contracts.assertValid(`${name}@1`, value); }
try { return contracts.assertValid(`${name}@1`, value); }
catch (err) { if (err.code === 'contract.invalid') err.status = 422; throw err; }
catch (err) { if (err.code === 'contract.invalid') err.status = 422; throw err; }
} }) {
} }) {
const enabledCheck = () => { if (!enabled) throw Object.assign(new Error('Work fabric is disabled'), { status: 404 }); };
const enabledCheck = () => { if (!enabled) throw Object.assign(new Error('Work fabric is disabled'), { status: 404 }); };
- async function transact(owner, agent, command) {
+
+ /** The estimate port for a decide: Actor's own finished runs of this agent, or "no data yet". */
+ async function estimator(owner, agent) {
+ try {
+ const rows = await s.db.many(`SELECT cost_usd FROM tasks WHERE requester = $1 AND via = $2 AND state = 'succeeded'
+ AND finished_at IS NOT NULL ORDER BY finished_at DESC, id DESC LIMIT 20`, [owner, `agent:${agent}`]);
+ const costs = rows.map(r => Number(r.cost_usd)).filter(v => Number.isFinite(v) && v > 0);
+ if (costs.length < ESTIMATE_RUNS) return null;
+ const sorted = [...costs].sort((a, b) => a - b);
+ return () => ({ low_usd: round(sorted[0]), high_usd: round(sorted[sorted.length - 1]), basis: `the last ${sorted.length} finished runs of this agent` });
+ } catch { return null; }
+ }
+
+ /** Observe records and lane changes are mirrored from the aggregate in the same transaction that commits it. */
+ async function mirror(db, agent, owner, state) {
+ const lastObserve = Number(await db.value('SELECT COALESCE(MAX(seq), 0) FROM actor_fabric_observe WHERE agent_id = $1', [agent])) || 0;
+ for (const r of state.observe || []) {
+ if (Number(r.seq) <= lastObserve) continue;
+ await db.query(`INSERT INTO actor_fabric_observe (id, agent_id, owner, seq, thread, rule_ids, record, created_at)
+ VALUES ($1,$2,$3,$4,$5,$6::text[],$7::jsonb,$8) ON CONFLICT (id) DO NOTHING`,
+ [r.id, agent, owner, Number(r.seq), r.thread || null, r.rule_ids || [], JSON.stringify(r), r.at || s.iso()]);
+ }
+ const lastLane = Number(await db.value('SELECT COALESCE(MAX(seq), 0) FROM actor_fabric_lane_changes WHERE agent_id = $1', [agent])) || 0;
+ for (const c of state.lane_changes || []) {
+ if (Number(c.seq) <= lastLane) continue;
+ await db.query(`INSERT INTO actor_fabric_lane_changes (id, agent_id, owner, seq, rule_id, from_mode, to_mode, reason, actor, created_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (id) DO NOTHING`,
+ [c.id, agent, owner, Number(c.seq), c.rule_id, c.from_mode, c.to_mode, c.reason, c.actor || 'system', c.at || s.iso()]);
+ }
+ }
+
+ async function transact(owner, agent, command, options = {}) {
enabledCheck();
enabledCheck();
return s.db.tx(async db => {
return s.db.tx(async db => {
// Authorization is inside the transaction and is repeated for every command, including leases.
// Authorization is inside the transaction and is repeated for every command, including leases.
@@ -17,9 +51,10 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =
@@ -17,9 +51,10 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =
if (!exists) throw Object.assign(new Error('Agent not found'), { status: 404 });
if (!exists) throw Object.assign(new Error('Agent not found'), { status: 404 });
await db.query('INSERT INTO actor_work_fabric (agent_id, owner, state) VALUES ($1,$2,$3::jsonb) ON CONFLICT DO NOTHING', [agent, owner, JSON.stringify(initial())]);
await db.query('INSERT INTO actor_work_fabric (agent_id, owner, state) VALUES ($1,$2,$3::jsonb) ON CONFLICT DO NOTHING', [agent, owner, JSON.stringify(initial())]);
const row = await db.one('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2 FOR UPDATE', [agent, owner]);
const row = await db.one('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2 FOR UPDATE', [agent, owner]);
- const engine = new Engine(row.state, { now: s.now(), validate });
+ const engine = new Engine(row.state, { now: s.now(), validate, estimate: options.estimate || null });
const result = command(engine);
const result = command(engine);
assert(!result || typeof result.then !== 'function', 'Engine commands must be synchronous');
assert(!result || typeof result.then !== 'function', 'Engine commands must be synchronous');
+ await mirror(db, agent, owner, engine.s);
await db.query('UPDATE actor_work_fabric SET state = $3::jsonb, revision = revision + 1, updated_at = $4 WHERE agent_id = $1 AND owner = $2', [agent, owner, JSON.stringify(engine.s), s.iso()]);
await db.query('UPDATE actor_work_fabric SET state = $3::jsonb, revision = revision + 1, updated_at = $4 WHERE agent_id = $1 AND owner = $2', [agent, owner, JSON.stringify(engine.s), s.iso()]);
return structuredClone(result ?? null);
return structuredClone(result ?? null);
});
});
@@ -32,8 +67,9 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =
@@ -32,8 +67,9 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =
if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });
if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });
return row;
return row;
},
},
- command(owner, agent, name, args = {}) {
+ async command(owner, agent, name, args = {}) {
assert(COMMANDS.has(name) || ['schedule', 'cancelSchedule', 'tick'].includes(name), 'Unknown fabric command');
assert(COMMANDS.has(name) || ['schedule', 'cancelSchedule', 'tick'].includes(name), 'Unknown fabric command');
+ const options = name === 'decide' ? { estimate: await estimator(owner, agent) } : {};
return transact(owner, agent, engine => {
return transact(owner, agent, engine => {
if (name === 'schedule') return sources.schedule(engine, args);
if (name === 'schedule') return sources.schedule(engine, args);
if (name === 'cancelSchedule') { assert(Object.hasOwn(engine.s.schedules, args.id), 'Unknown schedule'); engine.s.schedules[args.id].cancelled = true; return true; }
if (name === 'cancelSchedule') { assert(Object.hasOwn(engine.s.schedules, args.id), 'Unknown schedule'); engine.s.schedules[args.id].cancelled = true; return true; }
@@ -48,7 +84,31 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =
@@ -48,7 +84,31 @@ function createFabric({ s, enabled = false, readMail, validate = (name, value) =
}
}
}
}
return engine[name](args);
return engine[name](args);
- });
+ }, options);
+ },
+ /** Observe records newest first, optionally for one lane. */
+ async observe(owner, agent, { lane = null, limit = 50 } = {}) {
+ enabledCheck();
+ assert(!lane || typeof lane === 'string', 'Invalid lane');
+ assert(Number.isInteger(limit) && limit >= 1 && limit <= 200, 'Invalid observe limit');
+ const row = await s.db.maybe('SELECT state, revision FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2', [agent, owner]);
+ if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });
+ const rows = lane
+ ? await s.db.many('SELECT record FROM actor_fabric_observe WHERE agent_id = $1 AND owner = $2 AND $3 = ANY(rule_ids) ORDER BY seq DESC LIMIT $4', [agent, owner, lane, limit])
+ : await s.db.many('SELECT record FROM actor_fabric_observe WHERE agent_id = $1 AND owner = $2 ORDER BY seq DESC LIMIT $3', [agent, owner, limit]);
+ return rows.map(r => r.record);
+ },
+ /** Per-lane observe counts and the last change, for the panel's lane row. */
+ async laneStats(owner, agent) {
+ enabledCheck();
+ const row = await s.db.maybe('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2', [agent, owner]);
+ if (!row) throw Object.assign(new Error('Fabric not found'), { status: 404 });
+ const observe = await s.db.many(`SELECT rule_id, count(*)::int AS count, max(created_at) AS last_at
+ FROM (SELECT unnest(rule_ids) AS rule_id, created_at FROM actor_fabric_observe WHERE agent_id = $1 AND owner = $2) r
+ GROUP BY rule_id`, [agent, owner]);
+ const changes = await s.db.many(`SELECT DISTINCT ON (rule_id) rule_id, from_mode, to_mode, reason, actor, created_at
+ FROM actor_fabric_lane_changes WHERE agent_id = $1 AND owner = $2 ORDER BY rule_id, seq DESC`, [agent, owner]);
+ return { state: row.state, observe, changes };
},
},
worker(owner, agent, ticket) {
worker(owner, agent, ticket) {
const allowed = new Set(['handBack', 'read', 'end', 'subscribe', 'unsubscribe', 'reply']);
const allowed = new Set(['handBack', 'read', 'end', 'subscribe', 'unsubscribe', 'reply']);
@@ -104,6 +104,9 @@ const TABLES = [
@@ -104,6 +104,9 @@ const TABLES = [
{ table: 'actor_work_fabric', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric.json', erase: 'delete' },
{ table: 'actor_work_fabric', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric.json', erase: 'delete' },
// The Actor runtime task behind a fabric Ticket (S4 executor adapters); the task row itself is exported above.
// The Actor runtime task behind a fabric Ticket (S4 executor adapters); the task row itself is exported above.
{ table: 'actor_fabric_tasks', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-tasks.json', erase: 'delete' },
{ table: 'actor_fabric_tasks', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-tasks.json', erase: 'delete' },
+ // What a lane in observe would have done and every promotion/demotion with its reason (S5).
+ { table: 'actor_fabric_observe', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-observe.json', erase: 'delete' },
+ { table: 'actor_fabric_lane_changes', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-lane-changes.json', erase: 'delete' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
// The voices they shared (agents/voice-shares.js): the public link, the document it holds and who shared it.
// The voices they shared (agents/voice-shares.js): the public link, the document it holds and who shared it.
// The document is their own text, and the link is theirs to stop sharing.
// The document is their own text, and the link is theirs to stop sharing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * S5 observe → auto through the service and the API on a real (PGlite) database: observe records are mirrored to SQL,
+ * the lanes endpoint says each lane's mode, promote/demote are commands, and the six report states are data.
+ */
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const contacts = require('../server/agents/contacts');
+const { createRoutes } = require('../server/fabric/api');
+
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const policy = () => ({
+ mode: 'observe', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }],
+ sources: { chat: { allowed: [A], bots: [], admission: { global: { unlisted_unaddressed: 'inferred' } } } },
+ budget: { usd: 1, tokens: 1000, wall_minutes: 10 },
+});
+
+(async () => {
+ const t = await boot({ env: { ACTOR_WORK_FABRIC: '1' } });
+ try {
+ assert.equal(typeof createRoutes, 'function');
+ const alice = t.network.addUser('alice'); alice.subject = A;
+ const bob = t.network.addUser('bob'); bob.subject = B;
+ const vibe = await contacts.ensureVibe(t.ctx.s, t.config, `user:${A}`);
+ const owner = `user:${A}`, agent = vibe.id, fabric = t.ctx.fabric;
+ const event = (id, text) => ({ event_id: `ev${id}`, event_type: 'chat.message.created', payload: { message_id: id, user_subject: A, text, room: { type: 'global' } } });
+ let first = null;
+
+ await check('observe: an inferred decision records the gate verdict and cost and launches nothing', async () => {
+ await fabric.command(owner, agent, 'configure', {
+ sources: { chat: { kind: 'chat', enabled: true, self: 'agent_actor', scopes: ['global'], receive_all: ['global'] } },
+ policy: policy(),
+ });
+ await fabric.onEvent(event(1, 'The broken test fails'));
+ await fabric.tick();
+ let state = (await fabric.snapshot(owner, agent)).state;
+ const thread = Object.keys(state.threads)[0];
+ assert.equal(state.threads[thread].evaluation.strength, 'inferred');
+ const triggerId = Object.keys(state.triggers)[0];
+ const assessment = {
+ decision: 'accept', scope: 'Fix the broken test',
+ direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: triggerId, quote: 'broken test' }] },
+ rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'The message names a broken test' }],
+ };
+ const out = await fabric.command(owner, agent, 'decide', { evaluation: state.threads[thread].evaluation.id, decision: 'accept', assessment });
+ assert.equal(out.decision.decision, 'ignore'); assert.equal(out.decision.would_accept, true);
+ assert.equal(Object.keys((await fabric.snapshot(owner, agent)).state.tickets).length, 0);
+ const records = await fabric.observe(owner, agent, {});
+ assert.equal(records.length, 1);
+ assert.equal(records[0].gate.strength, 'inferred'); assert.equal(records[0].would_accept, true);
+ assert.deepEqual(records[0].rule_ids, ['fix']); assert.equal(records[0].estimate.basis, 'no data yet');
+ const mirrored = await t.ctx.s.db.value('SELECT count(*)::int FROM actor_fabric_observe WHERE agent_id = $1', [agent]);
+ assert.equal(mirrored, 1);
+ });
+
+ await check('lanes API: the panel gets each lane\'s mode, observe count and the six report states', async () => {
+ const res = await t.get(`/api/v1/fabric/${agent}/lanes`, { as: alice });
+ assert.equal(res.status, 200);
+ const body = res.json();
+ assert.equal(body.mode, 'observe'); assert.equal(body.lanes.length, 1);
+ assert.equal(body.lanes[0].rule_id, 'fix'); assert.equal(body.lanes[0].mode, 'observe');
+ assert.equal(body.lanes[0].observe.count, 1);
+ assert.deepEqual(body.report_states.map(s => s.state), ['progress', 'waiting', 'ready', 'blocked', 'failed', 'dropped']);
+ const obs = await t.get(`/api/v1/fabric/${agent}/observe?lane=fix&limit=10`, { as: alice });
+ assert.equal(obs.status, 200); assert.equal(obs.json().records.length, 1);
+ assert.equal(obs.json().records[0].decision, 'ignore');
+ });
+
+ await check('promote: the owner moves the lane to auto, and the next inferred work launches', async () => {
+ const res = await t.get(`/api/v1/fabric/${agent}/commands/promote`, { as: alice, json: { lane: 'fix' }, headers: { Origin: 'https://openvibe.actor' } });
+ assert.equal(res.status, 200); assert.equal(res.json().mode, 'auto');
+ const lanes = (await t.get(`/api/v1/fabric/${agent}/lanes`, { as: alice })).json();
+ assert.equal(lanes.mode, 'auto'); assert.equal(lanes.lanes[0].mode, 'auto');
+ assert.match(lanes.lanes[0].last_change.reason, /promoted this lane/);
+ await fabric.onEvent(event(2, 'The next broken test fails'));
+ await fabric.tick();
+ const state = (await fabric.snapshot(owner, agent)).state;
+ const thread = Object.keys(state.threads).find((key) => state.threads[key].evaluation);
+ const triggerId = state.threads[thread].evaluation.primary;
+ const assessment = { decision: 'accept', scope: 'Fix the next broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: triggerId, quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Names a broken test' }] };
+ const out = await fabric.command(owner, agent, 'decide', { evaluation: state.threads[thread].evaluation.id, decision: 'accept', assessment });
+ assert.equal(out.decision.decision, 'accept'); assert.ok(out.ticket);
+ first = out.ticket;
+ const after = await fabric.observe(owner, agent, { lane: 'fix' });
+ assert.equal(after[0].mode, 'auto'); assert.equal(after[0].decision, 'accept');
+ });
+
+ await check('spent budget: a report that uses the mandate budget demotes the lane with the number in words', async () => {
+ const res = await t.get(`/api/v1/fabric/${agent}/commands/report`, { as: alice, json: { ticket_id: first.id, spent_usd: 1, report: { ticket_id: first.id, assignment_id: 'asg_1', state: 'progress', summary: 'Working', pr: null, tests_run: [], risks: [] } }, headers: { Origin: 'https://openvibe.actor' } });
+ assert.equal(res.status, 200);
+ const lanes = (await t.get(`/api/v1/fabric/${agent}/lanes`, { as: alice })).json();
+ assert.equal(lanes.lanes[0].mode, 'observe');
+ assert.match(lanes.lanes[0].demote_reason, /used its whole budget of \$1\.00/);
+ assert.equal(lanes.lanes[0].report_counts.progress, 1);
+ const changes = await t.ctx.s.db.many('SELECT * FROM actor_fabric_lane_changes WHERE agent_id = $1 ORDER BY seq', [agent]);
+ assert.equal(changes.length, 2);
+ assert.equal(changes.at(-1).actor, 'system');
+ });
+
+ await check('reports API and the six states: ticket reports are data, not prose', async () => {
+ const body = (await t.get(`/api/v1/fabric/${agent}/reports`, { as: alice })).json();
+ assert.equal(body.tickets.length, 1); assert.equal(body.tickets[0].reports[0].state, 'progress');
+ assert.equal(body.report_states.length, 6);
+ const states = (await t.get(`/api/v1/fabric/${agent}/report-states`, { as: alice })).json();
+ assert.deepEqual(states.report_states.map(s => s.state), ['progress', 'waiting', 'ready', 'blocked', 'failed', 'dropped']);
+ assert.equal((await t.get(`/api/v1/fabric/${agent}/commands/demote`, { as: alice, json: { lane: 'fix', reason: 'Reading a few more observe records first.' }, headers: { Origin: 'https://openvibe.actor' } })).status, 200);
+ });
+
+ await check('owner-only: another signed-in person gets 404 from every new read', async () => {
+ for (const path of ['lanes', 'observe', 'reports']) assert.equal((await t.get(`/api/v1/fabric/${agent}/${path}`, { as: bob })).status, 404);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+/**
+ * S5 observe → auto (plan W4): lanes start in observe, record what they would have done with the gate's verdict and an
+ * estimated cost while doing nothing; the owner promotes one lane to auto; a failed gate or a spent budget puts it
+ * back in observe with the reason said in words. The six report states are data.
+ */
+const assert = require('node:assert/strict');
+const { Engine, initial } = require('../server/fabric/engine');
+const { REPORT_STATES, REPORT_STATE_VALUES, REPORT_STAGES, reportStates, emptyReportCounts } = require('../server/fabric/reports');
+
+const cfg = () => ({
+ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['room'], receive_all: ['room'] } },
+ policy: {
+ mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }],
+ sources: { chat: { allowed: ['alice'], bots: [], admission: { room: { unlisted_addressed: 'inferred', unlisted_unaddressed: 'inferred' } } } },
+ budget: { usd: 1, tokens: 1000, wall_minutes: 10 },
+ },
+});
+const trigger = (id = 'a', extra = {}) => ({ id, source: 'chat', actor: { source: 'chat', id: 'bob', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'none', ...extra });
+const good = () => ({ decision: 'accept', scope: 'Fix the broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Requested a defect fix' }] });
+const engine = (options = {}) => {
+ const e = new Engine(initial(), { now: 1000, estimate: () => ({ low_usd: 0.02, high_usd: 0.06, basis: 'the last 3 finished runs of this agent' }), ...options });
+ e.configure(cfg());
+ return e;
+};
+const assessOnce = (e) => { e.receive(trigger()); const ev = e.start({ thread: 'room' }); return { ev, decision: e.decide({ evaluation: ev.id, decision: 'accept', assessment: good() }) }; };
+const report = (ticket, state = 'progress', extra = {}) => ({ ticket_id: ticket.id, assignment_id: 'asg_1', state, summary: state === 'ready' ? 'Done' : 'Working', pr: null, tests_run: [], risks: [], ...extra });
+
+let count = 0;
+function test(name, fn) { fn(); count++; console.log(`ok ${name}`); }
+
+test('lane default: a rule without a mode starts in observe, records would-accept and launches nothing', () => {
+ const e = engine(), { decision } = assessOnce(e);
+ assert.equal(decision.decision.decision, 'ignore'); assert.equal(decision.decision.would_accept, true);
+ assert(!e.s.work.some(w => ['launch', 'reply'].includes(w.kind)));
+ assert.equal(e.s.observe.length, 1);
+ const [o] = e.s.observe;
+ assert.deepEqual(o.rule_ids, ['fix']); assert.equal(o.mode, 'observe');
+ assert.deepEqual(o.gate, { admitted: true, strength: 'inferred', basis: 'table', decision: 'accept' });
+ assert.equal(o.would_accept, true); assert.deepEqual(o.estimate, { low_usd: 0.02, high_usd: 0.06, basis: 'the last 3 finished runs of this agent' });
+ assert.match(o.would_do.summary, /Would have started work/); assert.match(o.reason, /Observe/);
+});
+
+test('promote and demote: one lane moves to auto by the owner and back with the reason said in words', () => {
+ const e = engine();
+ const promoted = e.promote({ lane: 'fix' });
+ assert.equal(promoted.mode, 'auto'); assert.equal(e.s.policy.mode, 'auto'); assert.equal(e.s.policy.rules[0].mode, 'auto');
+ assert.equal(e.s.lane_changes[0].from_mode, 'observe'); assert.equal(e.s.lane_changes[0].actor, 'owner');
+ const { decision } = assessOnce(e);
+ assert.equal(decision.decision.decision, 'accept'); assert(e.s.work.some(w => w.kind === 'launch'));
+ const demoted = e.demote({ lane: 'fix', reason: 'The owner wants to read a few more runs first.' });
+ assert.equal(demoted.mode, 'observe'); assert.equal(e.s.policy.rules[0].mode, 'observe');
+ assert.equal(e.s.policy.rules[0].demote_reason, 'The owner wants to read a few more runs first.');
+ assert.equal(e.s.lane_changes.at(-1).to_mode, 'observe'); assert.equal(e.s.lane_changes.at(-1).actor, 'owner');
+});
+
+test('failed gate: an unavailable assessment in auto drops every auto lane back to observe with the reason', () => {
+ const e = engine(); e.promote({ lane: 'fix' });
+ e.receive(trigger()); const ev = e.start({ thread: 'room' });
+ const out = e.decide({ evaluation: ev.id, decision: 'unavailable', reason: 'The assessment model could not return a usable answer.' });
+ assert.equal(out.decision.decision, 'unavailable');
+ assert.equal(e.s.policy.rules[0].mode, 'observe');
+ assert.match(e.s.policy.rules[0].demote_reason, /The gate could not decide: The assessment model could not return a usable answer/);
+ assert.deepEqual(e.s.observe[0].demoted_rules, ['fix']);
+ assert.equal(e.s.lane_changes.at(-1).actor, 'system');
+});
+
+test('spent budget: a report that uses the mandate budget puts the lane back in observe, in words', () => {
+ const e = engine(); e.promote({ lane: 'fix' });
+ const { decision } = assessOnce(e); const k = decision.ticket;
+ e.report({ ticket_id: k.id, report: report(k, 'progress'), spent_usd: 0.4, task_id: 'tsk_1' });
+ assert.equal(e.s.meta[k.id].spent_usd, 0.4); assert.equal(e.s.policy.rules[0].mode, 'auto');
+ e.report({ ticket_id: k.id, report: report(k, 'progress'), spent_usd: 0.6, task_id: 'tsk_2' });
+ assert.equal(e.s.meta[k.id].spent_usd, 1); assert.equal(e.s.policy.rules[0].mode, 'observe');
+ assert.match(e.s.policy.rules[0].demote_reason, /used its whole budget of \$1\.00/);
+ assert.match(e.s.meta[k.id].budget_stopped.reason, /\$1\.00/);
+ // A retried task end does not count its spend twice.
+ e.report({ ticket_id: k.id, report: report(k, 'progress'), spent_usd: 0.6, task_id: 'tsk_2' });
+ assert.equal(e.s.meta[k.id].spent_usd, 1);
+});
+
+test('gate record: a message no gate admits is still a readable verdict with nothing to run', () => {
+ const e = new Engine(initial(), { now: 1000 });
+ const c = cfg(); c.policy.sources.chat.allowed = ['alice']; c.policy.sources.chat.admission.room = { unlisted_unaddressed: 'ignore' };
+ e.configure(c); e.receive(trigger('a', { actor: { source: 'chat', id: 'stranger', kind: 'person' } }));
+ const ev = e.start({ thread: 'room' }); assert.equal(ev, null);
+ const o = e.s.observe.at(-1);
+ assert.deepEqual(o.gate, { admitted: false, strength: 'inferred' }); assert.equal(o.would_accept, false);
+ assert.match(o.would_do.summary, /nothing would run/); assert.deepEqual(o.estimate, { low_usd: 0, high_usd: 0, basis: 'nothing would run' });
+});
+
+test('report states: the six values and their stages are data, and every count starts at zero', () => {
+ assert.deepEqual(REPORT_STATE_VALUES, ['progress', 'waiting', 'ready', 'blocked', 'failed', 'dropped']);
+ assert.equal(REPORT_STATES.length, 6);
+ for (const s of REPORT_STATES) { assert(s.label); assert(s.meaning); assert(REPORT_STAGES[s.state]); }
+ assert.deepEqual(reportStates().map(s => s.state), REPORT_STATE_VALUES);
+ assert.deepEqual(emptyReportCounts(), { progress: 0, waiting: 0, ready: 0, blocked: 0, failed: 0, dropped: 0 });
+});
+
+console.log(`${count} fabric observe tests passed`);
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const { Engine, initial } = require('../server/fabric/engine');
const { Engine, initial } = require('../server/fabric/engine');
const { gate, policy } = require('../server/fabric/policy');
const { gate, policy } = require('../server/fabric/policy');
const { translate, receiveEvent, schedule, tick } = require('../server/fabric/sources');
const { translate, receiveEvent, schedule, tick } = require('../server/fabric/sources');
-const cfg = () => ({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['room'], receive_all: ['room'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }], sources: { chat: { allowed: ['alice'], bots: ['ci'], admission: { room: { unlisted_addressed: 'inferred', unlisted_unaddressed: 'inferred' } } } }, budget: { usd: 1, tokens: 1000, wall_minutes: 10 } } });
+const cfg = () => ({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['room'], receive_all: ['room'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects', mode: 'auto' }], sources: { chat: { allowed: ['alice'], bots: ['ci'], admission: { room: { unlisted_addressed: 'inferred', unlisted_unaddressed: 'inferred' } } } }, budget: { usd: 1, tokens: 1000, wall_minutes: 10 } } });
const trigger = (id, extra = {}) => ({ id, source: 'chat', actor: { source: 'chat', id: 'alice', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'mention', ...extra });
const trigger = (id, extra = {}) => ({ id, source: 'chat', actor: { source: 'chat', id: 'alice', kind: 'person' }, source_scope: 'room', threads: ['room'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'mention', ...extra });
const good = () => ({ decision: 'accept', scope: 'Fix the broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Requested defect fix' }] });
const good = () => ({ decision: 'accept', scope: 'Fix the broken test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'Requested defect fix' }] });
const engine = () => { const e = new Engine(initial(), { now: 1000 }); e.configure(cfg()); return e; };
const engine = () => { const e = new Engine(initial(), { now: 1000 }); e.configure(cfg()); return e; };