openvibers/openvibe.actor Commits
Wire the fabric to Actor's runtime and models (#396)
Changes
@@@ -1,6 +1,6 @@# Actor work fabric (S4)--`ACTOR_WORK_FABRIC=1` enables the backend. It is off by default. Each agent starts with `observe` policy, empty allowed lists and zero budget. There are no UI changes. Configured Sources take over matching events from the legacy Chat/Mail intake, so an event cannot launch through both paths. Unconfigured events retain the existing path.++`ACTOR_WORK_FABRIC=1` enables the backend and its executor/model adapters. It is off by default. Each agent starts with `observe` policy, empty allowed lists and zero budget. There are no UI changes. Configured Sources take over matching events from the legacy Chat/Mail intake, so an event cannot launch through both paths. Unconfigured events retain the existing path.## Transaction and integration boundary@@@ -30,10 +30,19 @@ Acceptance creates an immutable Mandate and `launch` work. Subscriptions deliver## Stored work--Adapters claim `route`, `assess`, `launch`, `input`, `end` or `reply` work, perform it after the claim commits, then settle using the claim token. `createFabric().drain(owner, agent, handlers)` provides this boundary for in-process adapters. Handlers receive a Ticket-bound worker port: it cannot change policy, accept work, claim a human stop, or create an authoritative subscription. No model/executor adapter is enabled automatically by the feature flag: routing/assessment/worker integrations consume these ports. A rollout needs authorized adapters connected before turning Sources on; otherwise work remains durably queued, and no model calls or spend occur.++Adapters claim `route`, `assess`, `launch`, `input`, `end` or `reply` work, perform it after the claim commits, then settle using the claim token. `createFabric().drain(owner, agent, handlers)` provides this boundary for in-process adapters. Handlers receive a Ticket-bound worker port: it cannot change policy, accept work, claim a human stop, or create an authoritative subscription.++++`server/fabric/adapters.js` is the adapter set the feature flag enables. It performs no work while the switch is off:++++- `route` is deterministic: an explicit human ask becomes a Ticket, with no model call.++- `assess` makes one cheap model call on Actor's model layer (`models.chat({tier:'check', json:true})`), shapes the answer as `agent.decision@1`, and records `unavailable` with a plain reason when the model cannot answer. It never invents evidence; the engine still checks every quote and every enabled rule.++- `launch` assigns the mandate to the owner's agent through `personal.run` — the same path as a personal agent's scheduled run — which creates a task and an assignment root. A follow-up `input` is one more run of the same agent, as a child of the Ticket's current task.++- `reply` posts the worker's answer back to the Chat room named by the Source scope (or replies to the Mail letter named by the Trigger's read pointer).++++The task-to-Ticket binding is written in the task's own insert transaction (`actor_fabric_tasks`, migration 0062), so a run can never end without a report target. When an Actor task ends, the task engine calls the adapter's `onTaskState`; it records one `agent.report@1` on the Ticket (`ready`, `failed` or `dropped`), ends the Ticket, and queues one reply. Tasks started by anything else have no binding and are ignored. `server/index.js` drains all fabrics every two seconds while the switch is on; the drain claims one item at a time and settles it before the next.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.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. The expand-only migration adds 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` exercises policy, intake, persistence, ownership, routing and delivery without external services.++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.nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- S4 executor adapters: one row binding a fabric Ticket to the Actor runtime task that runs it. Written in the++-- task's own insert transaction (server/fabric/adapters.js afterInsert), so a task can never exist without its++-- Ticket binding and a task end can always find the report target. The fabric aggregate remains the boundary for++-- Triggers, mandates and work; this table is the task-to-Ticket index only.++CREATE TABLE actor_fabric_tasks (++ task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,++ agent_id TEXT NOT NULL REFERENCES actor_agents(id) ON DELETE CASCADE,++ owner TEXT NOT NULL,++ ticket_id TEXT NOT NULL,++ created_at TIMESTAMPTZ NOT NULL DEFAULT now()++);++CREATE INDEX actor_fabric_tasks_owner ON actor_fabric_tasks(owner, agent_id);@@@ -260,7 +260,7 @@ function personText(task) {return end < 0 ? t : t.slice(0, end);}--async function run(ctx, agent, input = '', { origin = 'person', level = null, triggerId = null, goal = null, parentId = null, mailNote = '', afterInsert = null } = {}) {++async function run(ctx, agent, input = '', { origin = 'person', level = null, triggerId = null, goal = null, parentId = null, mailNote = '', afterInsert = null, budgetUsd = null } = {}) {const { s, config, engine, webhooks } = ctx;if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };// A goal's run (plan HO3) does a piece of the work and then answers the goal's done-test, in one line@@@ -271,7 +271,10 @@ async function run(ctx, agent, input = '', { origin = 'person', level = null, tr// The skills this agent has installed (plan HO2): their name and description go in the prompt, never their// instructions. The agent loads a skill itself when one of them fits the job; a withdrawn one is not offered.const installed = (await skillsStore.forAgent(s, agent.owner, agent.id)).filter((k) => !k.withdrawn);-- const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, parentId, body: { task: prompt(agent, notes, runInput, installed, { mailNote }), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) }, ...(level ? { level } : {}) }, assignmentOrigin: origin, assignmentLevel: level, triggerId, afterInsert, beforeInsert: async () => {++ // A work fabric mandate may cap the run below the agent's own budget; the lower of the two always wins.++ const capped = budgetUsd == null ? null : Number(budgetUsd);++ const cap = capped != null && Number.isFinite(capped) && capped >= 0 ? Math.min(Number(agent.budget_per_run_usd), capped) : Number(agent.budget_per_run_usd);++ const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, parentId, body: { task: prompt(agent, notes, runInput, installed, { mailNote }), mode: agent.mode, budget: { per_task_usd: cap }, ...(level ? { level } : {}) }, assignmentOrigin: origin, assignmentLevel: level, triggerId, afterInsert, beforeInsert: async () => {const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));@@@ -175,6 +175,10 @@ async function createApp(opts = {}) {engine.setDay(ctx.day);ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, triggers, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });engine.setMailReplies((task) => ctx.agentMail.answered(task));++ // The fabric's executor and model adapters (plan S4): work only moves while the owner switch is on, and the job++ // below drains it. A run's end comes back here as an agent.report@1 on its Ticket.++ ctx.fabricAdapters = require('./fabric/adapters').createFabricAdapters({ s, config, fabric: ctx.fabric, models, ctx, log, chat: chatClient, mail: fabricMail });++ engine.setFabric(ctx.fabricAdapters.onTaskState);// The skills registry (plan HO2, ADR-058): OpenVibe's own skills are documents in the repository (skills/), and// each one is scanned before it is offered. The seed publishes them as listings of publisher kind 'openvibe'.ctx.skills = require('./skills');@@@ -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'];++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 MIGRATIONS = path.join(__dirname, '..', 'migrations');const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');nnew file mode 100644@@@ -0,0 +1,293 @@++'use strict';++++/**++ * The fabric's executor and model adapters (S4, plan W2/W3).++ *++ * The fabric stores work and never calls a model or a network port itself. This module performs that work through the++ * ports Actor already has:++ *++ * route deterministic: an explicit human ask becomes one Ticket (no model call)++ * assess one cheap model call on Actor's model layer, shaped into agent.decision@1; no answer is invented++ * launch the Ticket's mandate is assigned to the owner's agent and run on Actor's one runtime (personal.run)++ * input a follow-up becomes one more run of the same agent, as a child of the Ticket's task++ * reply the worker's answer goes back to the Source thread (Chat; Mail when the original letter is known)++ * end the Ticket ended; there is nothing left to deliver++ *++ * The task engine calls onTaskState when a run ends, and that turns the run's outcome into an agent.report@1 report on++ * the Ticket (ready, failed or dropped). This is behind ACTOR_WORK_FABRIC, which is off by default; no adapter starts++ * unless the owner switch is on.++ */++const contracts = require('openvibe-contracts');++const contacts = require('../agents/contacts');++const personal = require('../agents/personal');++++const ROUTER_HANDLERS = ['route', 'assess', 'launch', 'input', 'end', 'reply'];++const ASSESS_SYSTEM = `You assess one incoming message for an agent's work rules. Return one JSON object and nothing else.++++You are the gate's assessment step, not the worker. Do not answer the message and do not invent work. Decide only++whether this message asks the agent to implement work under one of its enabled rules.++- "accept" only when the message asks for implementation, the target is specified by the message or an existing++ standard the message names, no design decision is required, and at least one enabled rule matches.++- "ignore" otherwise.++- "unavailable" only when the message cannot be read.++++Every enabled rule must appear exactly once in rule_assessments. Quote evidence verbatim from the trigger text; a++quote that is not in the text invalidates the assessment. Return:++{"decision":"accept|ignore|unavailable","scope":"one short sentence naming the work","direction":{"implementation_requested":true|false,"target_basis":"specified|existing-standard|undetermined","requires_design_decision":true|false,"evidence":[{"trigger_id":"...","quote":"..."}]},"rule_assessments":[{"rule_id":"...","matched":true|false,"reason":"one short sentence"}]}`;++++const clip = (value, n) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, n);++const stripFences = (text) => String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');++++/** The trigger text a run starts from, with the pointer rather than a pasted page (plan W6.7). */++function inputFor(trigger, ticket) {++ const who = trigger.actor && trigger.actor.kind === 'person' ? 'A person' : trigger.actor && trigger.actor.kind === 'bot' ? 'A bot' : 'A system';++ const scope = clip(ticket && ticket.mandate && ticket.mandate.scope, 400);++ const budget = ticket && ticket.mandate && ticket.mandate.budget ? ticket.mandate.budget : {};++ return [++ `${who} asked through ${trigger.source} (${trigger.source_scope}):`,++ clip(trigger.text, 4000),++ trigger.read_pointer ? `Original: ${trigger.read_pointer}` : '',++ scope ? `The mandate covers: ${scope}` : '',++ `Budget for this work: ${Number(budget.usd) || 0} USD, ${Number(budget.tokens) || 0} tokens, ${Number(budget.wall_minutes) || 0} wall minutes. Stay inside it.`,++ ].filter(Boolean).join('\n');++}++++/** One report for the Ticket, from the run's own row. The six states are agent.report@1's. */++function reportFor(row, ticket) {++ const ready = row.state === 'succeeded';++ const dropped = row.state === 'cancelled';++ let summary = '';++ if (ready) {++ let result = row.result;++ if (typeof result === 'string') { try { result = JSON.parse(result); } catch { /* plain text */ } }++ summary = clip(result && typeof result === 'object' ? (result.answer || result.text || JSON.stringify(result)) : result, 600);++ if (!summary) summary = 'The job finished.';++ } else if (dropped) summary = 'The job was stopped before it finished.';++ else summary = clip(row.error_detail || row.error_code || 'The job failed.', 600);++ const report = {++ ticket_id: ticket.id,++ assignment_id: row.assignment_id || row.id,++ state: ready ? 'ready' : dropped ? 'dropped' : 'failed',++ summary,++ pr: null,++ tests_run: [],++ risks: ready ? [] : [summary],++ };++ contracts.assertValid('agent.report@1', report);++ return report;++}++++function createFabricAdapters({ s, config, fabric, models, ctx: taskCtx = null, log = console, agentFor = null, runAgent = null, chat = null, mail = null }) {++ const getAgent = agentFor || ((owner, id) => contacts.get(s, owner, id));++ const ctx = taskCtx || { s, config, engine: null, webhooks: null };++ const run = runAgent || ((runCtx, agent, input, options) => personal.run(runCtx, agent, input, options));++++ /** The owner's agent, or null when the row vanished. A Ticket for a missing agent fails visibly, never retries. */++ async function agentOf(owner, id) {++ const agent = await getAgent(owner, id);++ if (!agent) { const err = new Error('The agent this Ticket belongs to is gone'); err.permanent = true; throw err; }++ return agent;++ }++++ async function snapshot(owner, agent) { return (await fabric.snapshot(owner, agent)).state; }++++ function sourceOf(state, id) { return state.sources[id] || null; }++++ /** The executor port: one run of the owner's agent for the Ticket, its task id bound to the Ticket. */++ async function assign(owner, agentId, ticket, trigger, parentId = null) {++ const agent = await agentOf(owner, agentId);++ const budgetUsd = ticket.mandate && ticket.mandate.budget ? Number(ticket.mandate.budget.usd) : null;++ if (budgetUsd != null && budgetUsd <= 0) {++ throw Object.assign(new Error('The mandate has a zero budget, so nothing was run.'), { permanent: true });++ }++ const out = await run(ctx, agent, inputFor(trigger, ticket), {++ budgetUsd,++ origin: trigger.actor && trigger.actor.kind === 'person' ? 'person' : 'routine',++ assignmentLevel: 'ask',++ parentId,++ // The binding commits with the task's own insert, so a run can never end without a report target.++ afterInsert: async (task) => {++ await s.db.query(`INSERT INTO actor_fabric_tasks (task_id, agent_id, owner, ticket_id, created_at)++ VALUES ($1, $2, $3, $4, $5) ON CONFLICT (task_id) DO NOTHING`, [task.id, agentId, owner, ticket.id, s.iso()]);++ },++ });++ if (!out || out.status !== 201 || !out.task) {++ const why = out && (out.detail || out.code) ? `${out.code || 'actor.fabric.assign'}: ${out.detail}` : 'The agent could not start';++ throw Object.assign(new Error(why), { permanent: true });++ }++ // The binding already committed with the task; this also records the current task for follow-ups.++ await fabric.command(owner, agentId, 'bind', { ticket_id: ticket.id, task_id: out.task.id })++ .catch((err) => log.warn && log.warn('[Actor] fabric bind:', ticket.id, (err && err.message) || err));++ return out.task;++ }++++ /** A permanent assignment failure is a failed report, not an endless retry. */++ async function reportFailure(owner, agentId, payload, reason) {++ const ticket = payload.ticket || (payload.ticket_id && (await snapshot(owner, agentId)).tickets[payload.ticket_id]);++ if (!ticket) return;++ const report = {++ ticket_id: ticket.id, assignment_id: ticket.id, state: 'failed',++ summary: clip(reason || 'The agent could not start.', 600), pr: null, tests_run: [],++ risks: [clip(reason || 'The agent could not start.', 300)],++ };++ contracts.assertValid('agent.report@1', report);++ await fabric.command(owner, agentId, 'report', { ticket_id: ticket.id, report, end: true });++ }++++ const handlers = (owner, agentId) => ({++ async route(payload) {++ const evaluation = payload.evaluation;++ const trigger = (payload.triggers || []).find((t) => t.id === evaluation.primary);++ if (!trigger) throw new Error('The evaluation has no primary trigger');++ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'accept', scope: clip(trigger.text, 400) });++ },++++ async assess(payload) {++ const evaluation = payload.evaluation;++ const triggers = payload.triggers || [];++ const rules = ((payload.policy && payload.policy.rules) || []).filter((r) => r.enabled);++ if (!rules.length) {++ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'ignore', reason: 'No enabled rule to assess against.' });++ return;++ }++ if (!models || typeof models.chat !== 'function') {++ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'unavailable', reason: 'Actor has no model configured to assess this work.' });++ return;++ }++ const prompt = [++ 'Enabled rules:',++ ...rules.map((r) => `- ${r.id}: ${clip(r.instruction, 1000)}`),++ '',++ 'Messages:',++ ...triggers.map((t) => `- trigger_id ${t.id} (${t.addresses_agent || 'none'}, ${t.actor && t.actor.kind}): ${clip(t.text, 4000)}`),++ ].join('\n');++ let assessment;++ try {++ const r = await models.chat({ tier: 'check', json: true, maxTokens: 1200, messages: [{ role: 'system', content: ASSESS_SYSTEM }, { role: 'user', content: prompt }] });++ assessment = JSON.parse(stripFences(r && r.text));++ } catch (err) {++ log.warn && log.warn('[Actor] fabric assessment unavailable:', (err && err.message) || err);++ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'unavailable', reason: 'The assessment model could not return a usable answer, so nothing ran.' });++ return;++ }++ try {++ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: assessment.decision, scope: assessment.scope, assessment });++ } catch (err) {++ // The engine validates evidence quotes and one assessment per enabled rule. A malformed answer is the++ // assessor's failure, not the worker's: record it as unavailable rather than retrying forever.++ log.warn && log.warn('[Actor] fabric assessment refused:', (err && err.message) || err);++ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'unavailable', reason: 'The assessment did not pass the gate, so nothing ran.' });++ }++ },++++ async launch(payload) {++ const ticket = payload.ticket;++ const state = await snapshot(owner, agentId);++ const trigger = state.triggers[ticket.trigger_id];++ if (!trigger) throw new Error('The Ticket has no originating Trigger');++ try { await assign(owner, agentId, ticket, trigger); }++ catch (err) { await reportFailure(owner, agentId, { ticket }, (err && err.message) || String(err)); }++ },++++ async input(payload) {++ if (payload.context_only) return;++ const state = await snapshot(owner, agentId);++ const ticket = state.tickets[payload.ticket_id];++ const trigger = payload.trigger;++ if (!ticket || !trigger) return;++ const meta = state.meta[ticket.id] || {};++ const parentId = meta.task_id || null;++ try { await assign(owner, agentId, ticket, trigger, parentId); }++ catch (err) { await reportFailure(owner, agentId, { ticket }, (err && err.message) || String(err)); }++ },++++ async end() { /* the Ticket ended; its task needs no further message */ },++++ async reply(payload) {++ const state = await snapshot(owner, agentId);++ const source = sourceOf(state, payload.source);++ if (!source) return;++ if (source.kind === 'chat' && chat) {++ const room = payload.source_scope === 'global' ? { type: 'global' }++ : /^channel:/.test(payload.source_scope) ? { type: 'channel', channel_user_id: payload.source_scope.slice(8) }++ : null;++ if (!room) return;++ const out = await chat.post({ key: payload.key, room, text: payload.text });++ if (!out || !out.ok) throw new Error((out && out.error) || 'Chat did not accept the reply');++ return;++ }++ if (source.kind === 'mail' && mail) {++ const ticket = (await snapshot(owner, agentId)).tickets[payload.ticket_id];++ const trigger = ticket && (await snapshot(owner, agentId)).triggers[ticket.trigger_id];++ const pointer = trigger && trigger.read_pointer;++ const messageId = pointer ? decodeURIComponent(String(pointer).split('/').pop()) : null;++ if (!messageId || messageId === 'undefined') return;++ const out = await mail.reply({ person: String(owner).replace(/^user:/, ''), agentId, messageId, body: payload.text });++ if (!out || !out.ok) throw new Error((out && (out.error || out.code)) || 'Mail did not accept the reply');++ }++ },++ });++++ /** One pump: claim and perform work for one agent until its queue is empty or the budget is used. */++ async function pump(owner, agentId, limit = 10) {++ const h = handlers(owner, agentId);++ for (let i = 0; i < limit; i++) {++ const did = await fabric.drain(owner, agentId, h).catch((err) => { log.warn && log.warn('[Actor] fabric work failed:', agentId, (err && err.message) || err); return true; });++ if (!did) return i;++ }++ return limit;++ }++++ /** Drain every fabric that has work. The index job calls this every couple of seconds while the switch is on. */++ async function drainAll(limit = 20) {++ if (!fabric.enabled) return 0;++ const rows = await s.db.many('SELECT agent_id, owner FROM actor_work_fabric ORDER BY updated_at, agent_id');++ let done = 0;++ for (const row of rows) {++ if (done >= limit) break;++ done += await pump(row.owner, row.agent_id, limit - done);++ }++ return done;++ }++++ /**++ * A task run ended: put its outcome on the Ticket as an agent.report@1 report and end the Ticket. The row's own++ * via/requester name the fabric and owner; a task from anywhere else is ignored.++ */++ async function onTaskState(row) {++ if (!fabric.enabled || !row || !['succeeded', 'failed', 'cancelled'].includes(row.state)) return;++ const via = String(row.via || '');++ if (!/^agent:agt_/.test(via)) return;++ let agentId = via.slice(6), owner = row.requester, ticketId = null;++ const link = await s.db.maybe('SELECT agent_id, owner, ticket_id FROM actor_fabric_tasks WHERE task_id = $1', [row.id]);++ if (link) { agentId = link.agent_id; owner = link.owner; ticketId = link.ticket_id; }++ const found = await s.db.maybe('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2', [agentId, owner]);++ if (!found) return;++ const state = found.state;++ // Older runs (before the binding table) are found by the Ticket's own task list.++ ticketId ||= Object.keys(state.tickets).find((id) => {++ const meta = state.meta[id] || {};++ return meta.task_id === row.id || (Array.isArray(meta.tasks) && meta.tasks.includes(row.id));++ });++ if (!ticketId) return;++ 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 });++ // One confirmation to the person on the Source thread, not a stream of progress.++ const source = state.sources[ticket.mandate.actor.source];++ if (source) {++ const text = report.state === 'ready'++ ? `${clip(report.summary, 500)}`++ : `I stopped: ${clip(report.summary, 500)}`;++ await fabric.command(owner, agentId, 'reply', {++ ticket_id: ticketId, conversation: ticket.mandate.source_scope,++ source: ticket.mandate.actor.source, key: `report:${row.id}`, text,++ }).catch((err) => log.warn && log.warn('[Actor] fabric report reply not queued:', (err && err.message) || err));++ }++ }++++ return { handlers, assign, drainAll, pump, onTaskState, ROUTER_HANDLERS };++}++++module.exports = { createFabricAdapters, inputFor, reportFor, ASSESS_SYSTEM, ROUTER_HANDLERS };@@@ -3,6 +3,8 @@ const { policy, gate, revalidate, assessment, assert, text } = require('./policyconst 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' });function initial() {return { policy: policy(), sources: {}, triggers: {}, threads: {}, tickets: {}, meta: {}, decisions: [], work: [], schedules: {}, accepted: {}, seq: 0 };}@@@ -139,13 +141,35 @@ class Engine {this.validate('agent.ticket', ticket);this.s.tickets[ticket.id] = ticket;for (const id of e.ids) this.s.accepted[id] = ticket.id;-- this.s.meta[ticket.id] = { basis: e.basis, owned: [], handbacks: [], read: [] };++ this.s.meta[ticket.id] = { basis: e.basis, owned: [], handbacks: [], read: [], reports: [], tasks: [] };for (const key of t.threads) this.thread(key).subscriptions[ticket.id] = 'origin';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 } : {}) };}++ /** 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 }) {++ const k = own(this.s.tickets, ticket_id), m = own(this.s.meta, ticket_id);++ assert(k && m && text(task_id), 'Unknown Ticket or task');++ m.task_id = task_id;++ m.tasks ||= [];++ if (!m.tasks.includes(task_id)) m.tasks.push(task_id);++ return k;++ }++ /** Record one agent.report@1 on the Ticket; a terminal report may end it. */++ report({ ticket_id, report, end = false }) {++ 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');++ const r = copy({ ...report, ticket_id });++ this.validate('agent.report', r);++ m.reports ||= [];++ m.reports.push(r);++ if (REPORT_STAGES[r.state]) k.stage = REPORT_STAGES[r.state];++ if (end) this.end({ ticket_id, reason: `report:${r.state}` });++ return { ticket: k, report: r };++ }subscribe({ ticket_id, thread, kind = 'context' }) {const k = own(this.s.tickets, ticket_id);assert(k && k.state === 'open', 'Ticket must be open');@@@ -285,4 +309,4 @@ class Engine {return w;}}--module.exports = { Engine, initial };++module.exports = { Engine, initial, REPORT_STAGES };@@@ -3,7 +3,7 @@ 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']);++const COMMANDS = new Set(['configure', 'start', 'decide', 'subscribe', 'attach', 'unsubscribe', 'handBack', 'read', 'end', 'stop', 'reply', 'claim', 'settle', 'delivered', 'bind', 'report']);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; }@@@ -102,6 +102,8 @@ const TABLES = [// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },{ 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' },{ 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.@@@ -71,6 +71,8 @@ async function start() {const extra = [() => { if (subscriptions) subscriptions.stop(); }];if (ctx.fabric.enabled && !config.drill) {extra.push(require('./utils/jobs').every('actor-fabric-time', 1000, () => ctx.fabric.tick(), { initialDelayMs: 1000, log: console }));++ // The work the Sources create is claimed and performed here; launch/input submit a task and return at once.++ extra.push(require('./utils/jobs').every('actor-fabric-work', 2000, () => ctx.fabricAdapters.drainAll(), { initialDelayMs: 1500, log: console }));}const dayJobs = ctx.day.startJobs();extra.push(() => { for (const stop of dayJobs) stop(); });@@@ -78,6 +78,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// approval. Injected like the triggers, so the engine does not depend on the mail module.let mailReplies = null;const setMailReplies = (m) => { mailReplies = m || null; };++ // The work fabric's executor reports a run's outcome on its Ticket (server/fabric/adapters.js). Injected like the++ // triggers and mail: the engine knows nothing about the fabric's storage.++ let fabric = null;++ const setFabric = (f) => { fabric = f || null; };const controls = new Map(); // task id → the run's control object (live assumptions)let stopped = false;let day = null;@@@ -210,6 +214,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =}if (day && row) await day.taskState(row);const saved = await store.updateTask(s, id, { state, ...patch });++ if (ended && fabric) await fabric(saved).catch((err) => log.warn && log.warn('[Actor] fabric task report:', id, (err && err.message) || err));if (ended) runTokens.drop(id);if (ended) stream.end(id);// Webhooks that asked for this state hear it; a delivery never holds the task up or fails it.@@@ -938,7 +943,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =const status = () => ({ running: running.size, waiting: waiting.length });-- return { submit, cancel, stop, status, answer, assume, steer, resumeInterrupted, resumeWithEvent, resumeWithNotice, failTask, notifyWaitTimeout, setWaits, setTriggers, setMailReplies, setDay, MAX_ATTEMPTS };++ return { submit, cancel, stop, status, answer, assume, steer, resumeInterrupted, resumeWithEvent, resumeWithNotice, failTask, notifyWaitTimeout, setWaits, setTriggers, setMailReplies, setFabric, setDay, MAX_ATTEMPTS };}module.exports = { createEngine, MAX_ATTEMPTS };nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/**++ * The fabric's executor and model adapters (S4, plan W2/W3), without a model or a network: the fabric's commands are++ * recorded, the model is a fixture, and the Actor runtime's task runner is a stand-in.++ */++const assert = require('node:assert/strict');++const { createFabricAdapters, reportFor } = require('../server/fabric/adapters');++const { Engine, initial } = require('../server/fabric/engine');++const { policy } = require('../server/fabric/policy');++++const OWNER = 'user:usr_01JZ0000000000000000000AAA';++const AGENT = 'agt_01JZ0000000000000000000AAA';++++function fakeFabric(state) {++ const commands = [];++ return {++ enabled: true,++ commands,++ async snapshot() { return { state }; },++ async command(owner, agent, name, args) { commands.push({ owner, agent, name, args }); return null; },++ async drain() { return false; },++ };++}++function fabricState({ ticket = null, source = { kind: 'chat', enabled: true, scopes: ['global'] } } = {}) {++ const e = new Engine(initial(), { now: 1000 });++ e.configure({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['global'], receive_all: ['global'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }], sources: { chat: { allowed: [], bots: [], admission: {} } } } });++ const trigger = { id: 'trigger_a', source: 'chat', actor: { source: 'chat', id: 'usr_person', kind: 'person' }, source_scope: 'global', threads: ['thread_a'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'mention', read_pointer: 'https://openvibe.chat/messages/1' };++ e.s.triggers[trigger.id] = trigger;++ e.s.sources.chat = source;++ if (ticket) { e.s.tickets[ticket.id] = ticket; e.s.meta[ticket.id] = { basis: 'addressed', owned: [], handbacks: [], read: [], reports: [], tasks: [] }; }++ return e.s;++}++const ticket = () => ({ id: 'ticket_1', trigger_id: 'trigger_a', mandate: { policy_version: 'v', scope: 'Fix the broken test', strength: 'explicit', matched_rules: [], actor: { source: 'chat', id: 'usr_person', kind: 'person' }, source_scope: 'global', budget: { usd: 1, tokens: 1000, wall_minutes: 10 } }, state: 'open', stage: 'working/queued', threads: ['thread_a'] });++++let count = 0;++function test(name, fn) { return Promise.resolve(fn()).then(() => { count++; console.log(`ok ${name}`); }); }++(async () => {++ await test('route: an explicit human ask becomes one Ticket without a model call', async () => {++ const fabric = fakeFabric(fabricState());++ let called = 0;++ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: { chat: async () => { called++; return { text: '{}' }; } }, runAgent: async () => null });++ await a.handlers(OWNER, AGENT).route({ evaluation: { id: 'eval_1', primary: 'trigger_a' }, triggers: [{ id: 'trigger_a', text: 'Fix the broken test' }] });++ assert.equal(called, 0);++ const decide = fabric.commands.find((c) => c.name === 'decide');++ assert.equal(decide.args.decision, 'accept'); assert.equal(decide.args.scope, 'Fix the broken test'); assert(!decide.args.assessment);++ });++++ await test('assess: one cheap model call carries every enabled rule and the trigger text', async () => {++ const fabric = fakeFabric(fabricState());++ const seen = [];++ const assessment = { decision: 'accept', scope: 'Fix one test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'trigger_a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'a defect is named' }] };++ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: { chat: async (args) => { seen.push(args); return { text: '```json\n' + JSON.stringify(assessment) + '\n```' }; } }, runAgent: async () => null });++ await a.handlers(OWNER, AGENT).assess({ evaluation: { id: 'eval_1', primary: 'trigger_a' }, triggers: [{ id: 'trigger_a', actor: { kind: 'person' }, text: 'The broken test fails' }], policy: { rules: [{ id: 'fix', enabled: true, instruction: 'Fix defects' }, { id: 'off', enabled: false, instruction: 'Ignore me' }] } });++ assert.equal(seen.length, 1); assert.equal(seen[0].tier, 'check');++ const prompt = seen[0].messages.map((m) => m.content).join('\n');++ assert.match(prompt, /fix: Fix defects/); assert.doesNotMatch(prompt, /off: Ignore me/);++ const decide = fabric.commands.find((c) => c.name === 'decide');++ assert.equal(decide.args.assessment.decision, 'accept');++ });++++ await test('assess: a model that cannot answer records unavailable and runs nothing', async () => {++ const fabric = fakeFabric(fabricState());++ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: { chat: async () => ({ text: 'not json' }) }, runAgent: async () => null });++ await a.handlers(OWNER, AGENT).assess({ evaluation: { id: 'eval_1', primary: 'trigger_a' }, triggers: [{ id: 'trigger_a', actor: { kind: 'person' }, text: 'Fix it' }], policy: { rules: [{ id: 'fix', enabled: true, instruction: 'Fix defects' }] } });++ const decide = fabric.commands.find((c) => c.name === 'decide');++ assert.equal(decide.args.decision, 'unavailable'); assert.match(decide.args.reason, /nothing ran/);++ });++++ await test('launch: the mandate is assigned to the owner\'s agent and the run is bound to the Ticket', async () => {++ const state = fabricState({ ticket: ticket() });++ const fabric = fakeFabric(state);++ const runs = [];++ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: {}, agentFor: async () => ({ id: AGENT, owner: OWNER }), runAgent: async (ctx, agent, input, opts) => { runs.push({ agent, input, opts }); return { status: 201, task: { id: 'tsk_1' } }; } });++ await a.handlers(OWNER, AGENT).launch({ ticket: state.tickets.ticket_1 });++ assert.equal(runs.length, 1); assert.match(runs[0].input, /Fix the broken test/); assert.match(runs[0].input, /1 USD/);++ assert.equal(runs[0].opts.origin, 'person'); assert.equal(runs[0].opts.budgetUsd, 1);++ const bind = fabric.commands.find((c) => c.name === 'bind');++ assert.equal(bind.args.ticket_id, 'ticket_1'); assert.equal(bind.args.task_id, 'tsk_1');++ });++++ await test('input: a context subscription is acknowledged without a run; a person\'s follow-up runs as a child', async () => {++ const state = fabricState({ ticket: ticket() }); state.meta.ticket_1.task_id = 'tsk_1';++ const fabric = fakeFabric(state);++ const runs = [];++ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: {}, agentFor: async () => ({ id: AGENT }), runAgent: async (ctx, agent, input, opts) => { runs.push(opts); return { status: 201, task: { id: `tsk_${runs.length + 1}` } }; } });++ await a.handlers(OWNER, AGENT).input({ ticket_id: 'ticket_1', trigger: { id: 'trigger_a', actor: { kind: 'person' }, source: 'chat', source_scope: 'global', text: 'Follow up' }, context_only: true });++ assert.equal(runs.length, 0);++ await a.handlers(OWNER, AGENT).input({ ticket_id: 'ticket_1', trigger: { id: 'trigger_a', actor: { kind: 'person' }, source: 'chat', source_scope: 'global', text: 'Follow up' } });++ assert.equal(runs.length, 1); assert.equal(runs[0].parentId, 'tsk_1');++ });++++ await test('reply: a Chat answer goes to the scope\'s room with the stable key', async () => {++ const state = fabricState({ ticket: ticket() });++ const fabric = fakeFabric(state);++ const posts = [];++ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: {}, chat: { post: async (args) => { posts.push(args); return { ok: true }; } } });++ await a.handlers(OWNER, AGENT).reply({ ticket_id: 'ticket_1', source: 'chat', source_scope: 'global', key: 'reply:1', text: 'Done' });++ assert.deepEqual(posts[0], { key: 'reply:1', room: { type: 'global' }, text: 'Done' });++ });++++ await test('task end: ready becomes an agent.report@1 on the Ticket, ends it and queues one reply', async () => {++ const state = fabricState({ ticket: ticket() }); state.meta.ticket_1.task_id = 'tsk_1';++ const fabric = fakeFabric(state);++ const s = { db: { maybe: async () => ({ state }) } };++ const a = createFabricAdapters({ s, config: {}, fabric, models: {} });++ await a.onTaskState({ id: 'tsk_1', via: `agent:${AGENT}`, requester: OWNER, state: 'succeeded', assignment_id: 'asg_1', result: { answer: 'The test is fixed.' } });++ const report = fabric.commands.find((c) => c.name === 'report');++ assert.equal(report.args.report.state, 'ready'); assert.equal(report.args.report.summary, 'The test is fixed.'); assert.equal(report.args.end, true);++ const reply = fabric.commands.find((c) => c.name === 'reply');++ assert.match(reply.args.text, /The test is fixed/); assert.equal(reply.args.key, 'report:tsk_1');++ });++++ await test('reportFor: failed and dropped are the executor\'s remaining report states', () => {++ assert.equal(reportFor({ id: 'tsk', state: 'failed', error_detail: 'the provider refused' }, { id: 'ticket' }).state, 'failed');++ assert.equal(reportFor({ id: 'tsk', state: 'cancelled' }, { id: 'ticket' }).state, 'dropped');++ assert.deepEqual(Object.keys(reportFor({ id: 'tsk', state: 'succeeded', result: 'ok' }, { id: 'ticket' })),++ ['ticket_id', 'assignment_id', 'state', 'summary', 'pr', 'tests_run', 'risks']);++ });++++ await test('engine: bind keeps the last task; report sets the stage and a terminal report ends the Ticket', () => {++ const e = new Engine(initial(), { now: 1000 });++ e.configure({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['global'], receive_all: ['global'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix defects' }], sources: { chat: { allowed: ['alice'], bots: [], admission: {} } } } });++ const t = { id: 'a', source: 'chat', actor: { source: 'chat', id: 'alice', kind: 'person' }, source_scope: 'global', threads: ['global'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix it', addresses_agent: 'mention' };++ e.receive(t); const ev = e.start({ thread: 'global' }); const k = e.decide({ evaluation: ev.id, decision: 'accept', scope: 'Fix it' }).ticket;++ e.bind({ ticket_id: k.id, task_id: 'tsk_1' }); e.bind({ ticket_id: k.id, task_id: 'tsk_2' });++ assert.deepEqual(e.s.meta[k.id].tasks, ['tsk_1', 'tsk_2']); assert.equal(e.s.meta[k.id].task_id, 'tsk_2');++ const report = { ticket_id: k.id, assignment_id: 'asg_1', state: 'progress', summary: 'Working', pr: null, tests_run: [], risks: [] };++ e.report({ ticket_id: k.id, report }); assert.equal(k.stage, 'working');++ e.report({ ticket_id: k.id, report: { ...report, state: 'ready', summary: 'Done' }, end: true });++ assert.equal(k.state, 'ended'); assert.equal(k.stage, 'person/review'); assert.equal(e.s.meta[k.id].reports.length, 2);++ assert.throws(() => e.report({ ticket_id: k.id, report }), /ended/);++ });++++ console.log(`${count} fabric adapter tests passed`);++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * The fabric's executor adapter on Actor's real task engine, with the stand-in providers (no live model calls): an++ * explicit Chat mention is gated, routed and launched; the owner's agent actually runs it; the run's end becomes one++ * agent.report@1 on the Ticket, the Ticket ends, and one reply is posted back to the room.++ */++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const contacts = require('../server/agents/contacts');++++const A = 'usr_01JZ0000000000000000000AAA';++++(async () => {++ const posts = [];++ const t = await boot({ env: { ACTOR_WORK_FABRIC: '1' }, chatClient: { post: async (args) => { posts.push(args); return { ok: true, id: 'msg_1' }; } } });++ try {++ const alice = t.network.addUser('alice'); alice.subject = A;++ const vibe = await contacts.ensureVibe(t.ctx.s, t.config, `user:${A}`);++ const agent = vibe.id;++ const owner = `user:${A}`;++ const fabric = t.ctx.fabric;++++ await check('executor: a mention is gated, launched on the runtime, reported ready, ended and answered once', async () => {++ await fabric.command(owner, agent, 'configure', {++ sources: { chat: { kind: 'chat', enabled: true, self: 'agent_actor', scopes: ['global'], mentions: ['actor'], receive_all: ['global'] } },++ policy: {++ mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }],++ budget: { usd: 1, tokens: 1000, wall_minutes: 10 },++ sources: { chat: { allowed: [A], bots: [], admission: { global: { unlisted_unaddressed: 'ignore' } } } },++ },++ });++ const intake = await fabric.onEvent({ event_id: 'ev1', event_type: 'chat.message.created', payload: {++ message_id: 1, user_subject: A, text: '@actor fix the failing test', room: { type: 'global' },++ } });++ assert.equal(intake[0].status, 'stored');++ await fabric.tick();++ const worked = await t.ctx.fabricAdapters.drainAll();++ assert.ok(worked >= 2, 'route and launch were performed');++++ let state = (await fabric.snapshot(owner, agent)).state;++ const ticket = Object.values(state.tickets)[0];++ assert.ok(ticket, 'a Ticket was created');++ assert.equal(state.meta[ticket.id].tasks.length, 1, 'one task was bound to the Ticket');++ const linked = await t.ctx.s.db.many('SELECT task_id FROM actor_fabric_tasks WHERE ticket_id = $1', [ticket.id]);++ assert.equal(linked.length, 1, 'the binding committed with the task');++++ const until = Date.now() + 20_000;++ while (Date.now() < until) {++ state = (await fabric.snapshot(owner, agent)).state;++ if (state.tickets[ticket.id].state === 'ended') break;++ await new Promise((r) => setTimeout(r, 50));++ }++ assert.equal(state.tickets[ticket.id].state, 'ended', 'the run ended the Ticket');++ assert.equal(state.tickets[ticket.id].stage, 'person/review');++ const reports = state.meta[ticket.id].reports;++ assert.equal(reports.length, 1);++ assert.equal(reports[0].state, 'ready');++ assert.ok(reports[0].summary.length > 0);++ assert.equal(state.meta[ticket.id].reports[0].pr, null);++++ // The reply is stored work too: it is posted once, with the run's answer.++ await t.ctx.fabricAdapters.drainAll();++ assert.equal(posts.length, 1);++ assert.deepEqual(posts[0].room, { type: 'global' });++ assert.match(posts[0].text, /Tool said|Stand-in answer/);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# Actor work fabric (S4)
# Actor work fabric (S4)
-`ACTOR_WORK_FABRIC=1` enables the backend. It is off by default. Each agent starts with `observe` policy, empty allowed lists and zero budget. There are no UI changes. Configured Sources take over matching events from the legacy Chat/Mail intake, so an event cannot launch through both paths. Unconfigured events retain the existing path.
+`ACTOR_WORK_FABRIC=1` enables the backend and its executor/model adapters. It is off by default. Each agent starts with `observe` policy, empty allowed lists and zero budget. There are no UI changes. Configured Sources take over matching events from the legacy Chat/Mail intake, so an event cannot launch through both paths. Unconfigured events retain the existing path.
## Transaction and integration boundary
## Transaction and integration boundary
@@ -30,10 +30,19 @@ Acceptance creates an immutable Mandate and `launch` work. Subscriptions deliver
@@ -30,10 +30,19 @@ Acceptance creates an immutable Mandate and `launch` work. Subscriptions deliver
## Stored work
## Stored work
-Adapters claim `route`, `assess`, `launch`, `input`, `end` or `reply` work, perform it after the claim commits, then settle using the claim token. `createFabric().drain(owner, agent, handlers)` provides this boundary for in-process adapters. Handlers receive a Ticket-bound worker port: it cannot change policy, accept work, claim a human stop, or create an authoritative subscription. No model/executor adapter is enabled automatically by the feature flag: routing/assessment/worker integrations consume these ports. A rollout needs authorized adapters connected before turning Sources on; otherwise work remains durably queued, and no model calls or spend occur.
+Adapters claim `route`, `assess`, `launch`, `input`, `end` or `reply` work, perform it after the claim commits, then settle using the claim token. `createFabric().drain(owner, agent, handlers)` provides this boundary for in-process adapters. Handlers receive a Ticket-bound worker port: it cannot change policy, accept work, claim a human stop, or create an authoritative subscription.
+
+`server/fabric/adapters.js` is the adapter set the feature flag enables. It performs no work while the switch is off:
+
+- `route` is deterministic: an explicit human ask becomes a Ticket, with no model call.
+- `assess` makes one cheap model call on Actor's model layer (`models.chat({tier:'check', json:true})`), shapes the answer as `agent.decision@1`, and records `unavailable` with a plain reason when the model cannot answer. It never invents evidence; the engine still checks every quote and every enabled rule.
+- `launch` assigns the mandate to the owner's agent through `personal.run` — the same path as a personal agent's scheduled run — which creates a task and an assignment root. A follow-up `input` is one more run of the same agent, as a child of the Ticket's current task.
+- `reply` posts the worker's answer back to the Chat room named by the Source scope (or replies to the Mail letter named by the Trigger's read pointer).
+
+The task-to-Ticket binding is written in the task's own insert transaction (`actor_fabric_tasks`, migration 0062), so a run can never end without a report target. When an Actor task ends, the task engine calls the adapter's `onTaskState`; it records one `agent.report@1` on the Ticket (`ready`, `failed` or `dropped`), ends the Ticket, and queues one reply. Tasks started by anything else have no binding and are ignored. `server/index.js` drains all fabrics every two seconds while the switch is on; the drain claims one item at a time and settles it before the next.
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.
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. The expand-only migration adds 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` exercises policy, intake, persistence, ownership, routing and delivery without external services.
+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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- S4 executor adapters: one row binding a fabric Ticket to the Actor runtime task that runs it. Written in the
+-- task's own insert transaction (server/fabric/adapters.js afterInsert), so a task can never exist without its
+-- Ticket binding and a task end can always find the report target. The fabric aggregate remains the boundary for
+-- Triggers, mandates and work; this table is the task-to-Ticket index only.
+CREATE TABLE actor_fabric_tasks (
+ task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
+ agent_id TEXT NOT NULL REFERENCES actor_agents(id) ON DELETE CASCADE,
+ owner TEXT NOT NULL,
+ ticket_id TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX actor_fabric_tasks_owner ON actor_fabric_tasks(owner, agent_id);
@@ -260,7 +260,7 @@ function personText(task) {
@@ -260,7 +260,7 @@ function personText(task) {
return end < 0 ? t : t.slice(0, end);
return end < 0 ? t : t.slice(0, end);
}
}
-async function run(ctx, agent, input = '', { origin = 'person', level = null, triggerId = null, goal = null, parentId = null, mailNote = '', afterInsert = null } = {}) {
+async function run(ctx, agent, input = '', { origin = 'person', level = null, triggerId = null, goal = null, parentId = null, mailNote = '', afterInsert = null, budgetUsd = null } = {}) {
const { s, config, engine, webhooks } = ctx;
const { s, config, engine, webhooks } = ctx;
if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };
if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };
// A goal's run (plan HO3) does a piece of the work and then answers the goal's done-test, in one line
// A goal's run (plan HO3) does a piece of the work and then answers the goal's done-test, in one line
@@ -271,7 +271,10 @@ async function run(ctx, agent, input = '', { origin = 'person', level = null, tr
@@ -271,7 +271,10 @@ async function run(ctx, agent, input = '', { origin = 'person', level = null, tr
// The skills this agent has installed (plan HO2): their name and description go in the prompt, never their
// The skills this agent has installed (plan HO2): their name and description go in the prompt, never their
// instructions. The agent loads a skill itself when one of them fits the job; a withdrawn one is not offered.
// instructions. The agent loads a skill itself when one of them fits the job; a withdrawn one is not offered.
const installed = (await skillsStore.forAgent(s, agent.owner, agent.id)).filter((k) => !k.withdrawn);
const installed = (await skillsStore.forAgent(s, agent.owner, agent.id)).filter((k) => !k.withdrawn);
- const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, parentId, body: { task: prompt(agent, notes, runInput, installed, { mailNote }), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) }, ...(level ? { level } : {}) }, assignmentOrigin: origin, assignmentLevel: level, triggerId, afterInsert, beforeInsert: async () => {
+ // A work fabric mandate may cap the run below the agent's own budget; the lower of the two always wins.
+ const capped = budgetUsd == null ? null : Number(budgetUsd);
+ const cap = capped != null && Number.isFinite(capped) && capped >= 0 ? Math.min(Number(agent.budget_per_run_usd), capped) : Number(agent.budget_per_run_usd);
+ const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, parentId, body: { task: prompt(agent, notes, runInput, installed, { mailNote }), mode: agent.mode, budget: { per_task_usd: cap }, ...(level ? { level } : {}) }, assignmentOrigin: origin, assignmentLevel: level, triggerId, afterInsert, beforeInsert: async () => {
const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);
const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);
if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };
if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };
const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));
const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));
@@ -175,6 +175,10 @@ async function createApp(opts = {}) {
@@ -175,6 +175,10 @@ async function createApp(opts = {}) {
engine.setDay(ctx.day);
engine.setDay(ctx.day);
ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, triggers, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });
ctx.agentMail = opts.agentMail || createAgentMail({ config, s, engine, webhooks, notifier, triggers, log, mailFetch: opts.mailFetch || null, mailTokens: opts.mailTokens || null });
engine.setMailReplies((task) => ctx.agentMail.answered(task));
engine.setMailReplies((task) => ctx.agentMail.answered(task));
+ // The fabric's executor and model adapters (plan S4): work only moves while the owner switch is on, and the job
+ // below drains it. A run's end comes back here as an agent.report@1 on its Ticket.
+ ctx.fabricAdapters = require('./fabric/adapters').createFabricAdapters({ s, config, fabric: ctx.fabric, models, ctx, log, chat: chatClient, mail: fabricMail });
+ engine.setFabric(ctx.fabricAdapters.onTaskState);
// The skills registry (plan HO2, ADR-058): OpenVibe's own skills are documents in the repository (skills/), and
// The skills registry (plan HO2, ADR-058): OpenVibe's own skills are documents in the repository (skills/), and
// each one is scanned before it is offered. The seed publishes them as listings of publisher kind 'openvibe'.
// each one is scanned before it is offered. The seed publishes them as listings of publisher kind 'openvibe'.
ctx.skills = require('./skills');
ctx.skills = require('./skills');
@@ -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'];
+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 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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,293 @@
@@ -0,0 +1,293 @@
+'use strict';
+
+/**
+ * The fabric's executor and model adapters (S4, plan W2/W3).
+ *
+ * The fabric stores work and never calls a model or a network port itself. This module performs that work through the
+ * ports Actor already has:
+ *
+ * route deterministic: an explicit human ask becomes one Ticket (no model call)
+ * assess one cheap model call on Actor's model layer, shaped into agent.decision@1; no answer is invented
+ * launch the Ticket's mandate is assigned to the owner's agent and run on Actor's one runtime (personal.run)
+ * input a follow-up becomes one more run of the same agent, as a child of the Ticket's task
+ * reply the worker's answer goes back to the Source thread (Chat; Mail when the original letter is known)
+ * end the Ticket ended; there is nothing left to deliver
+ *
+ * The task engine calls onTaskState when a run ends, and that turns the run's outcome into an agent.report@1 report on
+ * the Ticket (ready, failed or dropped). This is behind ACTOR_WORK_FABRIC, which is off by default; no adapter starts
+ * unless the owner switch is on.
+ */
+const contracts = require('openvibe-contracts');
+const contacts = require('../agents/contacts');
+const personal = require('../agents/personal');
+
+const ROUTER_HANDLERS = ['route', 'assess', 'launch', 'input', 'end', 'reply'];
+const ASSESS_SYSTEM = `You assess one incoming message for an agent's work rules. Return one JSON object and nothing else.
+
+You are the gate's assessment step, not the worker. Do not answer the message and do not invent work. Decide only
+whether this message asks the agent to implement work under one of its enabled rules.
+- "accept" only when the message asks for implementation, the target is specified by the message or an existing
+ standard the message names, no design decision is required, and at least one enabled rule matches.
+- "ignore" otherwise.
+- "unavailable" only when the message cannot be read.
+
+Every enabled rule must appear exactly once in rule_assessments. Quote evidence verbatim from the trigger text; a
+quote that is not in the text invalidates the assessment. Return:
+{"decision":"accept|ignore|unavailable","scope":"one short sentence naming the work","direction":{"implementation_requested":true|false,"target_basis":"specified|existing-standard|undetermined","requires_design_decision":true|false,"evidence":[{"trigger_id":"...","quote":"..."}]},"rule_assessments":[{"rule_id":"...","matched":true|false,"reason":"one short sentence"}]}`;
+
+const clip = (value, n) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, n);
+const stripFences = (text) => String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+
+/** The trigger text a run starts from, with the pointer rather than a pasted page (plan W6.7). */
+function inputFor(trigger, ticket) {
+ const who = trigger.actor && trigger.actor.kind === 'person' ? 'A person' : trigger.actor && trigger.actor.kind === 'bot' ? 'A bot' : 'A system';
+ const scope = clip(ticket && ticket.mandate && ticket.mandate.scope, 400);
+ const budget = ticket && ticket.mandate && ticket.mandate.budget ? ticket.mandate.budget : {};
+ return [
+ `${who} asked through ${trigger.source} (${trigger.source_scope}):`,
+ clip(trigger.text, 4000),
+ trigger.read_pointer ? `Original: ${trigger.read_pointer}` : '',
+ scope ? `The mandate covers: ${scope}` : '',
+ `Budget for this work: ${Number(budget.usd) || 0} USD, ${Number(budget.tokens) || 0} tokens, ${Number(budget.wall_minutes) || 0} wall minutes. Stay inside it.`,
+ ].filter(Boolean).join('\n');
+}
+
+/** One report for the Ticket, from the run's own row. The six states are agent.report@1's. */
+function reportFor(row, ticket) {
+ const ready = row.state === 'succeeded';
+ const dropped = row.state === 'cancelled';
+ let summary = '';
+ if (ready) {
+ let result = row.result;
+ if (typeof result === 'string') { try { result = JSON.parse(result); } catch { /* plain text */ } }
+ summary = clip(result && typeof result === 'object' ? (result.answer || result.text || JSON.stringify(result)) : result, 600);
+ if (!summary) summary = 'The job finished.';
+ } else if (dropped) summary = 'The job was stopped before it finished.';
+ else summary = clip(row.error_detail || row.error_code || 'The job failed.', 600);
+ const report = {
+ ticket_id: ticket.id,
+ assignment_id: row.assignment_id || row.id,
+ state: ready ? 'ready' : dropped ? 'dropped' : 'failed',
+ summary,
+ pr: null,
+ tests_run: [],
+ risks: ready ? [] : [summary],
+ };
+ contracts.assertValid('agent.report@1', report);
+ return report;
+}
+
+function createFabricAdapters({ s, config, fabric, models, ctx: taskCtx = null, log = console, agentFor = null, runAgent = null, chat = null, mail = null }) {
+ const getAgent = agentFor || ((owner, id) => contacts.get(s, owner, id));
+ const ctx = taskCtx || { s, config, engine: null, webhooks: null };
+ const run = runAgent || ((runCtx, agent, input, options) => personal.run(runCtx, agent, input, options));
+
+ /** The owner's agent, or null when the row vanished. A Ticket for a missing agent fails visibly, never retries. */
+ async function agentOf(owner, id) {
+ const agent = await getAgent(owner, id);
+ if (!agent) { const err = new Error('The agent this Ticket belongs to is gone'); err.permanent = true; throw err; }
+ return agent;
+ }
+
+ async function snapshot(owner, agent) { return (await fabric.snapshot(owner, agent)).state; }
+
+ function sourceOf(state, id) { return state.sources[id] || null; }
+
+ /** The executor port: one run of the owner's agent for the Ticket, its task id bound to the Ticket. */
+ async function assign(owner, agentId, ticket, trigger, parentId = null) {
+ const agent = await agentOf(owner, agentId);
+ const budgetUsd = ticket.mandate && ticket.mandate.budget ? Number(ticket.mandate.budget.usd) : null;
+ if (budgetUsd != null && budgetUsd <= 0) {
+ throw Object.assign(new Error('The mandate has a zero budget, so nothing was run.'), { permanent: true });
+ }
+ const out = await run(ctx, agent, inputFor(trigger, ticket), {
+ budgetUsd,
+ origin: trigger.actor && trigger.actor.kind === 'person' ? 'person' : 'routine',
+ assignmentLevel: 'ask',
+ parentId,
+ // The binding commits with the task's own insert, so a run can never end without a report target.
+ afterInsert: async (task) => {
+ await s.db.query(`INSERT INTO actor_fabric_tasks (task_id, agent_id, owner, ticket_id, created_at)
+ VALUES ($1, $2, $3, $4, $5) ON CONFLICT (task_id) DO NOTHING`, [task.id, agentId, owner, ticket.id, s.iso()]);
+ },
+ });
+ if (!out || out.status !== 201 || !out.task) {
+ const why = out && (out.detail || out.code) ? `${out.code || 'actor.fabric.assign'}: ${out.detail}` : 'The agent could not start';
+ throw Object.assign(new Error(why), { permanent: true });
+ }
+ // The binding already committed with the task; this also records the current task for follow-ups.
+ await fabric.command(owner, agentId, 'bind', { ticket_id: ticket.id, task_id: out.task.id })
+ .catch((err) => log.warn && log.warn('[Actor] fabric bind:', ticket.id, (err && err.message) || err));
+ return out.task;
+ }
+
+ /** A permanent assignment failure is a failed report, not an endless retry. */
+ async function reportFailure(owner, agentId, payload, reason) {
+ const ticket = payload.ticket || (payload.ticket_id && (await snapshot(owner, agentId)).tickets[payload.ticket_id]);
+ if (!ticket) return;
+ const report = {
+ ticket_id: ticket.id, assignment_id: ticket.id, state: 'failed',
+ summary: clip(reason || 'The agent could not start.', 600), pr: null, tests_run: [],
+ risks: [clip(reason || 'The agent could not start.', 300)],
+ };
+ contracts.assertValid('agent.report@1', report);
+ await fabric.command(owner, agentId, 'report', { ticket_id: ticket.id, report, end: true });
+ }
+
+ const handlers = (owner, agentId) => ({
+ async route(payload) {
+ const evaluation = payload.evaluation;
+ const trigger = (payload.triggers || []).find((t) => t.id === evaluation.primary);
+ if (!trigger) throw new Error('The evaluation has no primary trigger');
+ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'accept', scope: clip(trigger.text, 400) });
+ },
+
+ async assess(payload) {
+ const evaluation = payload.evaluation;
+ const triggers = payload.triggers || [];
+ const rules = ((payload.policy && payload.policy.rules) || []).filter((r) => r.enabled);
+ if (!rules.length) {
+ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'ignore', reason: 'No enabled rule to assess against.' });
+ return;
+ }
+ if (!models || typeof models.chat !== 'function') {
+ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'unavailable', reason: 'Actor has no model configured to assess this work.' });
+ return;
+ }
+ const prompt = [
+ 'Enabled rules:',
+ ...rules.map((r) => `- ${r.id}: ${clip(r.instruction, 1000)}`),
+ '',
+ 'Messages:',
+ ...triggers.map((t) => `- trigger_id ${t.id} (${t.addresses_agent || 'none'}, ${t.actor && t.actor.kind}): ${clip(t.text, 4000)}`),
+ ].join('\n');
+ let assessment;
+ try {
+ const r = await models.chat({ tier: 'check', json: true, maxTokens: 1200, messages: [{ role: 'system', content: ASSESS_SYSTEM }, { role: 'user', content: prompt }] });
+ assessment = JSON.parse(stripFences(r && r.text));
+ } catch (err) {
+ log.warn && log.warn('[Actor] fabric assessment unavailable:', (err && err.message) || err);
+ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'unavailable', reason: 'The assessment model could not return a usable answer, so nothing ran.' });
+ return;
+ }
+ try {
+ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: assessment.decision, scope: assessment.scope, assessment });
+ } catch (err) {
+ // The engine validates evidence quotes and one assessment per enabled rule. A malformed answer is the
+ // assessor's failure, not the worker's: record it as unavailable rather than retrying forever.
+ log.warn && log.warn('[Actor] fabric assessment refused:', (err && err.message) || err);
+ await fabric.command(owner, agentId, 'decide', { evaluation: evaluation.id, decision: 'unavailable', reason: 'The assessment did not pass the gate, so nothing ran.' });
+ }
+ },
+
+ async launch(payload) {
+ const ticket = payload.ticket;
+ const state = await snapshot(owner, agentId);
+ const trigger = state.triggers[ticket.trigger_id];
+ if (!trigger) throw new Error('The Ticket has no originating Trigger');
+ try { await assign(owner, agentId, ticket, trigger); }
+ catch (err) { await reportFailure(owner, agentId, { ticket }, (err && err.message) || String(err)); }
+ },
+
+ async input(payload) {
+ if (payload.context_only) return;
+ const state = await snapshot(owner, agentId);
+ const ticket = state.tickets[payload.ticket_id];
+ const trigger = payload.trigger;
+ if (!ticket || !trigger) return;
+ const meta = state.meta[ticket.id] || {};
+ const parentId = meta.task_id || null;
+ try { await assign(owner, agentId, ticket, trigger, parentId); }
+ catch (err) { await reportFailure(owner, agentId, { ticket }, (err && err.message) || String(err)); }
+ },
+
+ async end() { /* the Ticket ended; its task needs no further message */ },
+
+ async reply(payload) {
+ const state = await snapshot(owner, agentId);
+ const source = sourceOf(state, payload.source);
+ if (!source) return;
+ if (source.kind === 'chat' && chat) {
+ const room = payload.source_scope === 'global' ? { type: 'global' }
+ : /^channel:/.test(payload.source_scope) ? { type: 'channel', channel_user_id: payload.source_scope.slice(8) }
+ : null;
+ if (!room) return;
+ const out = await chat.post({ key: payload.key, room, text: payload.text });
+ if (!out || !out.ok) throw new Error((out && out.error) || 'Chat did not accept the reply');
+ return;
+ }
+ if (source.kind === 'mail' && mail) {
+ const ticket = (await snapshot(owner, agentId)).tickets[payload.ticket_id];
+ const trigger = ticket && (await snapshot(owner, agentId)).triggers[ticket.trigger_id];
+ const pointer = trigger && trigger.read_pointer;
+ const messageId = pointer ? decodeURIComponent(String(pointer).split('/').pop()) : null;
+ if (!messageId || messageId === 'undefined') return;
+ const out = await mail.reply({ person: String(owner).replace(/^user:/, ''), agentId, messageId, body: payload.text });
+ if (!out || !out.ok) throw new Error((out && (out.error || out.code)) || 'Mail did not accept the reply');
+ }
+ },
+ });
+
+ /** One pump: claim and perform work for one agent until its queue is empty or the budget is used. */
+ async function pump(owner, agentId, limit = 10) {
+ const h = handlers(owner, agentId);
+ for (let i = 0; i < limit; i++) {
+ const did = await fabric.drain(owner, agentId, h).catch((err) => { log.warn && log.warn('[Actor] fabric work failed:', agentId, (err && err.message) || err); return true; });
+ if (!did) return i;
+ }
+ return limit;
+ }
+
+ /** Drain every fabric that has work. The index job calls this every couple of seconds while the switch is on. */
+ async function drainAll(limit = 20) {
+ if (!fabric.enabled) return 0;
+ const rows = await s.db.many('SELECT agent_id, owner FROM actor_work_fabric ORDER BY updated_at, agent_id');
+ let done = 0;
+ for (const row of rows) {
+ if (done >= limit) break;
+ done += await pump(row.owner, row.agent_id, limit - done);
+ }
+ return done;
+ }
+
+ /**
+ * A task run ended: put its outcome on the Ticket as an agent.report@1 report and end the Ticket. The row's own
+ * via/requester name the fabric and owner; a task from anywhere else is ignored.
+ */
+ async function onTaskState(row) {
+ if (!fabric.enabled || !row || !['succeeded', 'failed', 'cancelled'].includes(row.state)) return;
+ const via = String(row.via || '');
+ if (!/^agent:agt_/.test(via)) return;
+ let agentId = via.slice(6), owner = row.requester, ticketId = null;
+ const link = await s.db.maybe('SELECT agent_id, owner, ticket_id FROM actor_fabric_tasks WHERE task_id = $1', [row.id]);
+ if (link) { agentId = link.agent_id; owner = link.owner; ticketId = link.ticket_id; }
+ const found = await s.db.maybe('SELECT state FROM actor_work_fabric WHERE agent_id = $1 AND owner = $2', [agentId, owner]);
+ if (!found) return;
+ const state = found.state;
+ // Older runs (before the binding table) are found by the Ticket's own task list.
+ ticketId ||= Object.keys(state.tickets).find((id) => {
+ const meta = state.meta[id] || {};
+ return meta.task_id === row.id || (Array.isArray(meta.tasks) && meta.tasks.includes(row.id));
+ });
+ if (!ticketId) return;
+ 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 });
+ // One confirmation to the person on the Source thread, not a stream of progress.
+ const source = state.sources[ticket.mandate.actor.source];
+ if (source) {
+ const text = report.state === 'ready'
+ ? `${clip(report.summary, 500)}`
+ : `I stopped: ${clip(report.summary, 500)}`;
+ await fabric.command(owner, agentId, 'reply', {
+ ticket_id: ticketId, conversation: ticket.mandate.source_scope,
+ source: ticket.mandate.actor.source, key: `report:${row.id}`, text,
+ }).catch((err) => log.warn && log.warn('[Actor] fabric report reply not queued:', (err && err.message) || err));
+ }
+ }
+
+ return { handlers, assign, drainAll, pump, onTaskState, ROUTER_HANDLERS };
+}
+
+module.exports = { createFabricAdapters, inputFor, reportFor, ASSESS_SYSTEM, ROUTER_HANDLERS };
@@ -3,6 +3,8 @@ const { policy, gate, revalidate, assessment, assert, text } = require('./policy
@@ -3,6 +3,8 @@ const { policy, gate, revalidate, assessment, assert, text } = require('./policy
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' });
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 };
}
}
@@ -139,13 +141,35 @@ class Engine {
@@ -139,13 +141,35 @@ class Engine {
this.validate('agent.ticket', ticket);
this.validate('agent.ticket', ticket);
this.s.tickets[ticket.id] = ticket;
this.s.tickets[ticket.id] = ticket;
for (const id of e.ids) this.s.accepted[id] = ticket.id;
for (const id of e.ids) this.s.accepted[id] = ticket.id;
- this.s.meta[ticket.id] = { basis: e.basis, owned: [], handbacks: [], read: [] };
+ this.s.meta[ticket.id] = { basis: e.basis, owned: [], handbacks: [], read: [], reports: [], tasks: [] };
for (const key of t.threads) this.thread(key).subscriptions[ticket.id] = 'origin';
for (const key of t.threads) this.thread(key).subscriptions[ticket.id] = 'origin';
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 } : {}) };
return { decision: this.finish(th, decision, audit), ...(ticket ? { ticket } : {}) };
}
}
+ /** 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 }) {
+ const k = own(this.s.tickets, ticket_id), m = own(this.s.meta, ticket_id);
+ assert(k && m && text(task_id), 'Unknown Ticket or task');
+ m.task_id = task_id;
+ m.tasks ||= [];
+ if (!m.tasks.includes(task_id)) m.tasks.push(task_id);
+ return k;
+ }
+ /** Record one agent.report@1 on the Ticket; a terminal report may end it. */
+ report({ ticket_id, report, end = false }) {
+ 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');
+ const r = copy({ ...report, ticket_id });
+ this.validate('agent.report', r);
+ m.reports ||= [];
+ m.reports.push(r);
+ if (REPORT_STAGES[r.state]) k.stage = REPORT_STAGES[r.state];
+ if (end) this.end({ ticket_id, reason: `report:${r.state}` });
+ return { ticket: k, report: r };
+ }
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);
assert(k && k.state === 'open', 'Ticket must be open');
assert(k && k.state === 'open', 'Ticket must be open');
@@ -285,4 +309,4 @@ class Engine {
@@ -285,4 +309,4 @@ class Engine {
return w;
return w;
}
}
}
}
-module.exports = { Engine, initial };
+module.exports = { Engine, initial, REPORT_STAGES };
@@ -3,7 +3,7 @@ const contracts = require('openvibe-contracts');
@@ -3,7 +3,7 @@ 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']);
+const COMMANDS = new Set(['configure', 'start', 'decide', 'subscribe', 'attach', 'unsubscribe', 'handBack', 'read', 'end', 'stop', 'reply', 'claim', 'settle', 'delivered', 'bind', 'report']);
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; }
@@ -102,6 +102,8 @@ const TABLES = [
@@ -102,6 +102,8 @@ const TABLES = [
// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).
// Which of their agents' runs went to their Vibe chats (agents/vibe-deliver.js).
{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },
{ table: 'actor_vibe_deliveries', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-runs-sent-to-vibe.json' },
{ 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.
+ { table: 'actor_fabric_tasks', subject: 'owner', value: (usr) => `user:${usr}`, file: 'work-fabric-tasks.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.
@@ -71,6 +71,8 @@ async function start() {
@@ -71,6 +71,8 @@ async function start() {
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
const extra = [() => { if (subscriptions) subscriptions.stop(); }];
if (ctx.fabric.enabled && !config.drill) {
if (ctx.fabric.enabled && !config.drill) {
extra.push(require('./utils/jobs').every('actor-fabric-time', 1000, () => ctx.fabric.tick(), { initialDelayMs: 1000, log: console }));
extra.push(require('./utils/jobs').every('actor-fabric-time', 1000, () => ctx.fabric.tick(), { initialDelayMs: 1000, log: console }));
+ // The work the Sources create is claimed and performed here; launch/input submit a task and return at once.
+ extra.push(require('./utils/jobs').every('actor-fabric-work', 2000, () => ctx.fabricAdapters.drainAll(), { initialDelayMs: 1500, log: console }));
}
}
const dayJobs = ctx.day.startJobs();
const dayJobs = ctx.day.startJobs();
extra.push(() => { for (const stop of dayJobs) stop(); });
extra.push(() => { for (const stop of dayJobs) stop(); });
@@ -78,6 +78,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -78,6 +78,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// approval. Injected like the triggers, so the engine does not depend on the mail module.
// approval. Injected like the triggers, so the engine does not depend on the mail module.
let mailReplies = null;
let mailReplies = null;
const setMailReplies = (m) => { mailReplies = m || null; };
const setMailReplies = (m) => { mailReplies = m || null; };
+ // The work fabric's executor reports a run's outcome on its Ticket (server/fabric/adapters.js). Injected like the
+ // triggers and mail: the engine knows nothing about the fabric's storage.
+ let fabric = null;
+ const setFabric = (f) => { fabric = f || null; };
const controls = new Map(); // task id → the run's control object (live assumptions)
const controls = new Map(); // task id → the run's control object (live assumptions)
let stopped = false;
let stopped = false;
let day = null;
let day = null;
@@ -210,6 +214,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -210,6 +214,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
}
}
if (day && row) await day.taskState(row);
if (day && row) await day.taskState(row);
const saved = await store.updateTask(s, id, { state, ...patch });
const saved = await store.updateTask(s, id, { state, ...patch });
+ if (ended && fabric) await fabric(saved).catch((err) => log.warn && log.warn('[Actor] fabric task report:', id, (err && err.message) || err));
if (ended) runTokens.drop(id);
if (ended) runTokens.drop(id);
if (ended) stream.end(id);
if (ended) stream.end(id);
// Webhooks that asked for this state hear it; a delivery never holds the task up or fails it.
// Webhooks that asked for this state hear it; a delivery never holds the task up or fails it.
@@ -938,7 +943,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -938,7 +943,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const status = () => ({ running: running.size, waiting: waiting.length });
const status = () => ({ running: running.size, waiting: waiting.length });
- return { submit, cancel, stop, status, answer, assume, steer, resumeInterrupted, resumeWithEvent, resumeWithNotice, failTask, notifyWaitTimeout, setWaits, setTriggers, setMailReplies, setDay, MAX_ATTEMPTS };
+ return { submit, cancel, stop, status, answer, assume, steer, resumeInterrupted, resumeWithEvent, resumeWithNotice, failTask, notifyWaitTimeout, setWaits, setTriggers, setMailReplies, setFabric, setDay, MAX_ATTEMPTS };
}
}
module.exports = { createEngine, MAX_ATTEMPTS };
module.exports = { createEngine, MAX_ATTEMPTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/**
+ * The fabric's executor and model adapters (S4, plan W2/W3), without a model or a network: the fabric's commands are
+ * recorded, the model is a fixture, and the Actor runtime's task runner is a stand-in.
+ */
+const assert = require('node:assert/strict');
+const { createFabricAdapters, reportFor } = require('../server/fabric/adapters');
+const { Engine, initial } = require('../server/fabric/engine');
+const { policy } = require('../server/fabric/policy');
+
+const OWNER = 'user:usr_01JZ0000000000000000000AAA';
+const AGENT = 'agt_01JZ0000000000000000000AAA';
+
+function fakeFabric(state) {
+ const commands = [];
+ return {
+ enabled: true,
+ commands,
+ async snapshot() { return { state }; },
+ async command(owner, agent, name, args) { commands.push({ owner, agent, name, args }); return null; },
+ async drain() { return false; },
+ };
+}
+function fabricState({ ticket = null, source = { kind: 'chat', enabled: true, scopes: ['global'] } } = {}) {
+ const e = new Engine(initial(), { now: 1000 });
+ e.configure({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['global'], receive_all: ['global'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }], sources: { chat: { allowed: [], bots: [], admission: {} } } } });
+ const trigger = { id: 'trigger_a', source: 'chat', actor: { source: 'chat', id: 'usr_person', kind: 'person' }, source_scope: 'global', threads: ['thread_a'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix the broken test', addresses_agent: 'mention', read_pointer: 'https://openvibe.chat/messages/1' };
+ e.s.triggers[trigger.id] = trigger;
+ e.s.sources.chat = source;
+ if (ticket) { e.s.tickets[ticket.id] = ticket; e.s.meta[ticket.id] = { basis: 'addressed', owned: [], handbacks: [], read: [], reports: [], tasks: [] }; }
+ return e.s;
+}
+const ticket = () => ({ id: 'ticket_1', trigger_id: 'trigger_a', mandate: { policy_version: 'v', scope: 'Fix the broken test', strength: 'explicit', matched_rules: [], actor: { source: 'chat', id: 'usr_person', kind: 'person' }, source_scope: 'global', budget: { usd: 1, tokens: 1000, wall_minutes: 10 } }, state: 'open', stage: 'working/queued', threads: ['thread_a'] });
+
+let count = 0;
+function test(name, fn) { return Promise.resolve(fn()).then(() => { count++; console.log(`ok ${name}`); }); }
+(async () => {
+ await test('route: an explicit human ask becomes one Ticket without a model call', async () => {
+ const fabric = fakeFabric(fabricState());
+ let called = 0;
+ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: { chat: async () => { called++; return { text: '{}' }; } }, runAgent: async () => null });
+ await a.handlers(OWNER, AGENT).route({ evaluation: { id: 'eval_1', primary: 'trigger_a' }, triggers: [{ id: 'trigger_a', text: 'Fix the broken test' }] });
+ assert.equal(called, 0);
+ const decide = fabric.commands.find((c) => c.name === 'decide');
+ assert.equal(decide.args.decision, 'accept'); assert.equal(decide.args.scope, 'Fix the broken test'); assert(!decide.args.assessment);
+ });
+
+ await test('assess: one cheap model call carries every enabled rule and the trigger text', async () => {
+ const fabric = fakeFabric(fabricState());
+ const seen = [];
+ const assessment = { decision: 'accept', scope: 'Fix one test', direction: { implementation_requested: true, target_basis: 'specified', requires_design_decision: false, evidence: [{ trigger_id: 'trigger_a', quote: 'broken test' }] }, rule_assessments: [{ rule_id: 'fix', matched: true, reason: 'a defect is named' }] };
+ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: { chat: async (args) => { seen.push(args); return { text: '```json\n' + JSON.stringify(assessment) + '\n```' }; } }, runAgent: async () => null });
+ await a.handlers(OWNER, AGENT).assess({ evaluation: { id: 'eval_1', primary: 'trigger_a' }, triggers: [{ id: 'trigger_a', actor: { kind: 'person' }, text: 'The broken test fails' }], policy: { rules: [{ id: 'fix', enabled: true, instruction: 'Fix defects' }, { id: 'off', enabled: false, instruction: 'Ignore me' }] } });
+ assert.equal(seen.length, 1); assert.equal(seen[0].tier, 'check');
+ const prompt = seen[0].messages.map((m) => m.content).join('\n');
+ assert.match(prompt, /fix: Fix defects/); assert.doesNotMatch(prompt, /off: Ignore me/);
+ const decide = fabric.commands.find((c) => c.name === 'decide');
+ assert.equal(decide.args.assessment.decision, 'accept');
+ });
+
+ await test('assess: a model that cannot answer records unavailable and runs nothing', async () => {
+ const fabric = fakeFabric(fabricState());
+ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: { chat: async () => ({ text: 'not json' }) }, runAgent: async () => null });
+ await a.handlers(OWNER, AGENT).assess({ evaluation: { id: 'eval_1', primary: 'trigger_a' }, triggers: [{ id: 'trigger_a', actor: { kind: 'person' }, text: 'Fix it' }], policy: { rules: [{ id: 'fix', enabled: true, instruction: 'Fix defects' }] } });
+ const decide = fabric.commands.find((c) => c.name === 'decide');
+ assert.equal(decide.args.decision, 'unavailable'); assert.match(decide.args.reason, /nothing ran/);
+ });
+
+ await test('launch: the mandate is assigned to the owner\'s agent and the run is bound to the Ticket', async () => {
+ const state = fabricState({ ticket: ticket() });
+ const fabric = fakeFabric(state);
+ const runs = [];
+ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: {}, agentFor: async () => ({ id: AGENT, owner: OWNER }), runAgent: async (ctx, agent, input, opts) => { runs.push({ agent, input, opts }); return { status: 201, task: { id: 'tsk_1' } }; } });
+ await a.handlers(OWNER, AGENT).launch({ ticket: state.tickets.ticket_1 });
+ assert.equal(runs.length, 1); assert.match(runs[0].input, /Fix the broken test/); assert.match(runs[0].input, /1 USD/);
+ assert.equal(runs[0].opts.origin, 'person'); assert.equal(runs[0].opts.budgetUsd, 1);
+ const bind = fabric.commands.find((c) => c.name === 'bind');
+ assert.equal(bind.args.ticket_id, 'ticket_1'); assert.equal(bind.args.task_id, 'tsk_1');
+ });
+
+ await test('input: a context subscription is acknowledged without a run; a person\'s follow-up runs as a child', async () => {
+ const state = fabricState({ ticket: ticket() }); state.meta.ticket_1.task_id = 'tsk_1';
+ const fabric = fakeFabric(state);
+ const runs = [];
+ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: {}, agentFor: async () => ({ id: AGENT }), runAgent: async (ctx, agent, input, opts) => { runs.push(opts); return { status: 201, task: { id: `tsk_${runs.length + 1}` } }; } });
+ await a.handlers(OWNER, AGENT).input({ ticket_id: 'ticket_1', trigger: { id: 'trigger_a', actor: { kind: 'person' }, source: 'chat', source_scope: 'global', text: 'Follow up' }, context_only: true });
+ assert.equal(runs.length, 0);
+ await a.handlers(OWNER, AGENT).input({ ticket_id: 'ticket_1', trigger: { id: 'trigger_a', actor: { kind: 'person' }, source: 'chat', source_scope: 'global', text: 'Follow up' } });
+ assert.equal(runs.length, 1); assert.equal(runs[0].parentId, 'tsk_1');
+ });
+
+ await test('reply: a Chat answer goes to the scope\'s room with the stable key', async () => {
+ const state = fabricState({ ticket: ticket() });
+ const fabric = fakeFabric(state);
+ const posts = [];
+ const a = createFabricAdapters({ s: {}, config: {}, fabric, models: {}, chat: { post: async (args) => { posts.push(args); return { ok: true }; } } });
+ await a.handlers(OWNER, AGENT).reply({ ticket_id: 'ticket_1', source: 'chat', source_scope: 'global', key: 'reply:1', text: 'Done' });
+ assert.deepEqual(posts[0], { key: 'reply:1', room: { type: 'global' }, text: 'Done' });
+ });
+
+ await test('task end: ready becomes an agent.report@1 on the Ticket, ends it and queues one reply', async () => {
+ const state = fabricState({ ticket: ticket() }); state.meta.ticket_1.task_id = 'tsk_1';
+ const fabric = fakeFabric(state);
+ const s = { db: { maybe: async () => ({ state }) } };
+ const a = createFabricAdapters({ s, config: {}, fabric, models: {} });
+ await a.onTaskState({ id: 'tsk_1', via: `agent:${AGENT}`, requester: OWNER, state: 'succeeded', assignment_id: 'asg_1', result: { answer: 'The test is fixed.' } });
+ const report = fabric.commands.find((c) => c.name === 'report');
+ assert.equal(report.args.report.state, 'ready'); assert.equal(report.args.report.summary, 'The test is fixed.'); assert.equal(report.args.end, true);
+ const reply = fabric.commands.find((c) => c.name === 'reply');
+ assert.match(reply.args.text, /The test is fixed/); assert.equal(reply.args.key, 'report:tsk_1');
+ });
+
+ await test('reportFor: failed and dropped are the executor\'s remaining report states', () => {
+ assert.equal(reportFor({ id: 'tsk', state: 'failed', error_detail: 'the provider refused' }, { id: 'ticket' }).state, 'failed');
+ assert.equal(reportFor({ id: 'tsk', state: 'cancelled' }, { id: 'ticket' }).state, 'dropped');
+ assert.deepEqual(Object.keys(reportFor({ id: 'tsk', state: 'succeeded', result: 'ok' }, { id: 'ticket' })),
+ ['ticket_id', 'assignment_id', 'state', 'summary', 'pr', 'tests_run', 'risks']);
+ });
+
+ await test('engine: bind keeps the last task; report sets the stage and a terminal report ends the Ticket', () => {
+ const e = new Engine(initial(), { now: 1000 });
+ e.configure({ sources: { chat: { kind: 'chat', self: 'agent', enabled: true, scopes: ['global'], receive_all: ['global'] } }, policy: { mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix defects' }], sources: { chat: { allowed: ['alice'], bots: [], admission: {} } } } });
+ const t = { id: 'a', source: 'chat', actor: { source: 'chat', id: 'alice', kind: 'person' }, source_scope: 'global', threads: ['global'], occurred_at: '2026-11-09T12:00:00.000Z', text: 'Fix it', addresses_agent: 'mention' };
+ e.receive(t); const ev = e.start({ thread: 'global' }); const k = e.decide({ evaluation: ev.id, decision: 'accept', scope: 'Fix it' }).ticket;
+ e.bind({ ticket_id: k.id, task_id: 'tsk_1' }); e.bind({ ticket_id: k.id, task_id: 'tsk_2' });
+ assert.deepEqual(e.s.meta[k.id].tasks, ['tsk_1', 'tsk_2']); assert.equal(e.s.meta[k.id].task_id, 'tsk_2');
+ const report = { ticket_id: k.id, assignment_id: 'asg_1', state: 'progress', summary: 'Working', pr: null, tests_run: [], risks: [] };
+ e.report({ ticket_id: k.id, report }); assert.equal(k.stage, 'working');
+ e.report({ ticket_id: k.id, report: { ...report, state: 'ready', summary: 'Done' }, end: true });
+ assert.equal(k.state, 'ended'); assert.equal(k.stage, 'person/review'); assert.equal(e.s.meta[k.id].reports.length, 2);
+ assert.throws(() => e.report({ ticket_id: k.id, report }), /ended/);
+ });
+
+ console.log(`${count} fabric adapter tests passed`);
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * The fabric's executor adapter on Actor's real task engine, with the stand-in providers (no live model calls): an
+ * explicit Chat mention is gated, routed and launched; the owner's agent actually runs it; the run's end becomes one
+ * agent.report@1 on the Ticket, the Ticket ends, and one reply is posted back to the room.
+ */
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const contacts = require('../server/agents/contacts');
+
+const A = 'usr_01JZ0000000000000000000AAA';
+
+(async () => {
+ const posts = [];
+ const t = await boot({ env: { ACTOR_WORK_FABRIC: '1' }, chatClient: { post: async (args) => { posts.push(args); return { ok: true, id: 'msg_1' }; } } });
+ try {
+ const alice = t.network.addUser('alice'); alice.subject = A;
+ const vibe = await contacts.ensureVibe(t.ctx.s, t.config, `user:${A}`);
+ const agent = vibe.id;
+ const owner = `user:${A}`;
+ const fabric = t.ctx.fabric;
+
+ await check('executor: a mention is gated, launched on the runtime, reported ready, ended and answered once', async () => {
+ await fabric.command(owner, agent, 'configure', {
+ sources: { chat: { kind: 'chat', enabled: true, self: 'agent_actor', scopes: ['global'], mentions: ['actor'], receive_all: ['global'] } },
+ policy: {
+ mode: 'auto', rules: [{ id: 'fix', enabled: true, instruction: 'Fix requested defects' }],
+ budget: { usd: 1, tokens: 1000, wall_minutes: 10 },
+ sources: { chat: { allowed: [A], bots: [], admission: { global: { unlisted_unaddressed: 'ignore' } } } },
+ },
+ });
+ const intake = await fabric.onEvent({ event_id: 'ev1', event_type: 'chat.message.created', payload: {
+ message_id: 1, user_subject: A, text: '@actor fix the failing test', room: { type: 'global' },
+ } });
+ assert.equal(intake[0].status, 'stored');
+ await fabric.tick();
+ const worked = await t.ctx.fabricAdapters.drainAll();
+ assert.ok(worked >= 2, 'route and launch were performed');
+
+ let state = (await fabric.snapshot(owner, agent)).state;
+ const ticket = Object.values(state.tickets)[0];
+ assert.ok(ticket, 'a Ticket was created');
+ assert.equal(state.meta[ticket.id].tasks.length, 1, 'one task was bound to the Ticket');
+ const linked = await t.ctx.s.db.many('SELECT task_id FROM actor_fabric_tasks WHERE ticket_id = $1', [ticket.id]);
+ assert.equal(linked.length, 1, 'the binding committed with the task');
+
+ const until = Date.now() + 20_000;
+ while (Date.now() < until) {
+ state = (await fabric.snapshot(owner, agent)).state;
+ if (state.tickets[ticket.id].state === 'ended') break;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ assert.equal(state.tickets[ticket.id].state, 'ended', 'the run ended the Ticket');
+ assert.equal(state.tickets[ticket.id].stage, 'person/review');
+ const reports = state.meta[ticket.id].reports;
+ assert.equal(reports.length, 1);
+ assert.equal(reports[0].state, 'ready');
+ assert.ok(reports[0].summary.length > 0);
+ assert.equal(state.meta[ticket.id].reports[0].pr, null);
+
+ // The reply is stored work too: it is posted once, with the run's answer.
+ await t.ctx.fabricAdapters.drainAll();
+ assert.equal(posts.length, 1);
+ assert.deepEqual(posts[0].room, { type: 'global' });
+ assert.match(posts[0].text, /Tool said|Stand-in answer/);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });