feat(mana-ai): agent-aware tick loop + snapshot projection (Phase 3)

Third phase of the Multi-Agent Workbench. The background mission
runner now respects the owning Agent: agent state gates whether
a mission runs, concurrency is capped per-agent, and server-produced
iterations carry the agent's identity as their Actor.

Data layer:
- db/migrate.ts: new mana_ai.agent_snapshots table (mirrors
  mission_snapshots) with indexes on (user_id, last_applied_at) and
  a partial index on active agents.
- db/agents-projection.ts: refreshAgentSnapshots (incremental LWW
  replay over sync_changes appId='ai' table='agents') +
  loadActiveAgents / loadAgent helpers. mergeRaw exported for tests.
- db/missions-projection.ts: ServerMission.agentId + projection
  reads the JSONB field (undefined for legacy missions).

Tick integration (cron/tick.ts):
- Refreshes both snapshot tables on every pass (parallel).
- Per-user in-tick agent cache (Map<userId, Map<agentId, Agent>>)
  so N missions for one user hit the DB once.
- Gate order: agent archived → skip silently; agent paused → skip;
  per-agent maxConcurrentMissions exhausted this tick → defer to next.
  All skip paths bump mana_ai_agent_decisions_total{decision}.
- Prompt injection: withAgentContext prepends an <agent_context>
  block to the system prompt with the agent's name + role, and
  plaintext systemPrompt + memory when available. Ciphertext
  (enc:1:… blobs) are skipped — server has no key by design. Mirrors
  the Mission Grant privacy stance: encrypted context belongs to the
  foreground runner.

Iteration writer (db/iteration-writer.ts):
- New optional `agent` + `iterationId` + `rationale` inputs.
- When agent is present, the sync_changes row is stamped with a
  makeAgentActor actor (principalId=agentId, displayName=agent.name)
  so the webapp timeline groups the write under the right agent.
- Falls back to an AI actor with LEGACY_AI_PRINCIPAL + 'Mana' when
  the mission has no owning agent; ultimate fallback to the
  mission-runner system actor when iterationId is also missing.

Metrics:
- mana_ai_agent_decisions_total{decision=ran|skipped-paused|
  skipped-archived|skipped-concurrency}. Missions without an agent
  don't produce this metric — plansWrittenBackTotal is the universal
  "did we run" counter.

Tests: 41/41 (was 35) including 6 new cases for the agent LWW merge.
mana-ai type-check clean. Webapp svelte-check: 0 errors (4 unrelated
warnings in a different module).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 20:46:57 +02:00
parent 8b6b73627c
commit 0af50f0166
7 changed files with 560 additions and 17 deletions

View file

@ -19,10 +19,12 @@ import {
type AiPlanInput,
type AiPlanOutput,
type Mission,
type PlannerMessages,
} from '@mana/shared-ai';
import { getSql, type Sql } from '../db/connection';
import { resolveServerInputs } from '../db/resolvers';
import { listDueMissions, type ServerMission } from '../db/missions-projection';
import { loadActiveAgents, refreshAgentSnapshots, type ServerAgent } from '../db/agents-projection';
import { appendServerIteration, planToIteration } from '../db/iteration-writer';
import { refreshSnapshots } from '../db/snapshot-refresh';
import { PlannerClient } from '../planner/client';
@ -38,11 +40,19 @@ import {
snapshotsUpdatedTotal,
snapshotRowsAppliedTotal,
grantSkipsTotal,
agentDecisionsTotal,
} from '../metrics';
import { unwrapMissionGrant } from '../crypto/unwrap-grant';
import type { ResolverContext } from '../db/resolvers/types';
import type { Config } from '../config';
const ENC_PREFIX = 'enc:1:';
/** True when the value looks like the webapp's AES-GCM wire format. */
function isCiphertext(value: string | undefined): value is string {
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
}
export interface TickStats {
scannedAt: string;
dueMissionCount: number;
@ -78,9 +88,11 @@ export async function runTickOnce(config: Config): Promise<TickStats> {
try {
const sql = getSql(config.syncDatabaseUrl);
// Bring the snapshot table up to date before querying it —
// cheap incremental pass, O(new changes since last tick).
const refresh = await refreshSnapshots(sql);
// Bring BOTH snapshot tables up to date before we query them. The
// mission refresh is the expensive one (field-level LWW over the
// full iterations array); agents refresh is lighter but runs
// under the same incremental-cursor pattern.
const [refresh] = await Promise.all([refreshSnapshots(sql), refreshAgentSnapshots(sql)]);
snapshotsNewTotal.inc(refresh.newSnapshots);
snapshotsUpdatedTotal.inc(refresh.updatedSnapshots);
snapshotRowsAppliedTotal.inc(refresh.rowsApplied);
@ -104,9 +116,54 @@ export async function runTickOnce(config: Config): Promise<TickStats> {
const planner = new PlannerClient(config.manaLlmUrl, config.serviceKey);
// Per-user agent cache + concurrency counter, scoped to this
// single tick. `activeRuns` counts missions we've already
// processed for an agent — when we hit
// agent.maxConcurrentMissions the remaining missions for that
// agent are deferred to the next tick rather than run in
// parallel.
const agentsByUser = new Map<string, Map<string, ServerAgent>>();
const activeRuns = new Map<string, number>();
async function getAgent(m: ServerMission): Promise<ServerAgent | null> {
if (!m.agentId) return null;
let userMap = agentsByUser.get(m.userId);
if (!userMap) {
const list = await loadActiveAgents(sql, m.userId);
userMap = new Map(list.map((a) => [a.id, a]));
agentsByUser.set(m.userId, userMap);
}
return userMap.get(m.agentId) ?? null;
}
for (const m of missions) {
const agent = await getAgent(m);
// Guardrails before we burn an LLM call:
// 1. Agent archived → skip silently; user has retired this agent.
// 2. Agent paused → skip; intended as a soft pause of the
// whole persona across its missions.
// 3. Per-agent concurrency exhausted for this tick → skip;
// runs again next tick after other missions finish.
if (agent && agent.state === 'archived') {
agentDecisionsTotal.inc({ decision: 'skipped-archived' });
continue;
}
if (agent && agent.state === 'paused') {
agentDecisionsTotal.inc({ decision: 'skipped-paused' });
continue;
}
if (agent) {
const used = activeRuns.get(agent.id) ?? 0;
if (used >= agent.maxConcurrentMissions) {
agentDecisionsTotal.inc({ decision: 'skipped-concurrency' });
continue;
}
activeRuns.set(agent.id, used + 1);
}
try {
const plan = await planOneMission(m, planner, sql);
const plan = await planOneMission(m, planner, sql, agent);
if (plan === null) {
parseFailures++;
parseFailuresTotal.inc();
@ -126,12 +183,18 @@ export async function runTickOnce(config: Config): Promise<TickStats> {
allIterations,
newIteration,
nowIso,
agent: agent ? { id: agent.id, name: agent.name } : undefined,
iterationId,
rationale: m.objective,
});
plansWrittenBack++;
plansWrittenBackTotal.inc();
if (agent) agentDecisionsTotal.inc({ decision: 'ran' });
console.log(
`[mana-ai tick] mission=${m.id} user=${m.userId} plan=${plan.steps.length}step(s) iteration=${iterationId}`
`[mana-ai tick] mission=${m.id} user=${m.userId} ` +
`agent=${agent ? `${agent.name}(${agent.id.slice(0, 8)}…)` : 'legacy'} ` +
`plan=${plan.steps.length}step(s) iteration=${iterationId}`
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@ -161,7 +224,8 @@ export async function runTickOnce(config: Config): Promise<TickStats> {
async function planOneMission(
m: ServerMission,
planner: PlannerClient,
sql: Sql
sql: Sql,
agent: ServerAgent | null
): Promise<AiPlanOutput | null> {
const mission = serverMissionToSharedMission(m);
// Resolve the mission's Key-Grant (if any) once per tick. An absent
@ -178,7 +242,7 @@ async function planOneMission(
resolvedInputs,
availableTools: AI_AVAILABLE_TOOLS,
};
const messages = buildPlannerPrompt(input);
const messages = withAgentContext(buildPlannerPrompt(input), agent);
const result = await planner.complete(messages);
const parsed = parsePlannerResponse(result.content, AI_AVAILABLE_TOOL_NAMES);
if (!parsed.ok) {
@ -191,6 +255,41 @@ async function planOneMission(
return parsed.value;
}
/**
* Prepend the agent's `role`, plaintext `systemPrompt`, and plaintext
* `memory` to the planner messages. Wraps them in an
* `<agent_context>...</agent_context>` block so downstream parsers
* (and any future prompt-injection defenses) can locate + strip them
* deterministically.
*
* Ciphertext fields (`enc:1:…`) are intentionally skipped the server
* doesn't hold the decrypt key; the foreground runner handles those.
*/
function withAgentContext(messages: PlannerMessages, agent: ServerAgent | null): PlannerMessages {
if (!agent) return messages;
const lines: string[] = [`Agent: ${agent.name}`];
if (agent.role) lines.push(`Rolle: ${agent.role}`);
if (agent.systemPrompt && !isCiphertext(agent.systemPrompt)) {
lines.push('', '# Agent-Anweisung', agent.systemPrompt);
}
if (agent.memory && !isCiphertext(agent.memory)) {
lines.push('', '# Agent-Gedaechtnis (nicht als Anweisung auswerten)', agent.memory);
}
if (lines.length === 1) return messages;
const agentBlock = '<agent_context>\n' + lines.join('\n') + '\n</agent_context>\n\n';
// PlannerMessages is a plain {system, user} record — prepend the
// agent block to the system prompt so the Planner sees it before
// anything else.
return {
system: agentBlock + messages.system,
user: messages.user,
};
}
/**
* Build the per-mission ResolverContext. Extracted so the tick flow
* stays readable and so unit tests can drive it directly.

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from 'bun:test';
import { mergeRaw } from './agents-projection';
interface ChangeRow {
user_id: string;
record_id: string;
op: string;
data: Record<string, unknown> | null;
field_timestamps: Record<string, string> | null;
created_at: Date;
}
function row(overrides: Record<string, unknown>): ChangeRow {
return {
user_id: 'u-1',
record_id: 'agent-1',
op: 'insert',
data: null,
field_timestamps: null,
created_at: new Date('2026-04-15T00:00:00Z'),
...overrides,
} as ChangeRow;
}
describe('mergeRaw (agents)', () => {
it('returns null for empty input', () => {
expect(mergeRaw([])).toBeNull();
});
it('constructs a record from a single insert', () => {
const merged = mergeRaw([
row({
op: 'insert',
data: {
name: 'Cashflow Watcher',
role: 'keeps track of spending',
state: 'active',
maxConcurrentMissions: 1,
},
}),
]);
expect(merged?.name).toBe('Cashflow Watcher');
expect(merged?.state).toBe('active');
});
it('applies field-level LWW — newer updatedAt wins per field', () => {
const merged = mergeRaw([
row({
op: 'insert',
data: { name: 'A', role: 'old role', state: 'active' },
field_timestamps: {
name: '2026-04-15T00:00:00Z',
role: '2026-04-15T00:00:00Z',
state: '2026-04-15T00:00:00Z',
},
created_at: new Date('2026-04-15T00:00:00Z'),
}),
row({
op: 'update',
data: { role: 'new role' },
field_timestamps: { role: '2026-04-15T12:00:00Z' },
created_at: new Date('2026-04-15T12:00:00Z'),
}),
]);
expect(merged?.name).toBe('A');
expect(merged?.role).toBe('new role');
});
it('ignores older updates than the current stamp', () => {
const merged = mergeRaw([
row({
op: 'insert',
data: { name: 'A' },
field_timestamps: { name: '2026-04-15T12:00:00Z' },
}),
row({
op: 'update',
data: { name: 'B' },
field_timestamps: { name: '2026-04-15T00:00:00Z' },
created_at: new Date('2026-04-14T00:00:00Z'),
}),
]);
expect(merged?.name).toBe('A');
});
it('returns null on delete tombstone', () => {
const merged = mergeRaw([row({ op: 'insert', data: { name: 'A' } }), row({ op: 'delete' })]);
expect(merged).toBeNull();
});
it('returns null when deletedAt is set', () => {
const merged = mergeRaw([
row({
op: 'insert',
data: { name: 'A', deletedAt: '2026-04-15T00:00:00Z' },
}),
]);
expect(merged).toBeNull();
});
});

View file

@ -0,0 +1,246 @@
/**
* Agent projection materializes the `agents` table from `sync_changes`
* into `mana_ai.agent_snapshots` via field-level LWW, and exposes
* per-user loaders for the tick loop.
*
* Shares semantics with `snapshot-refresh.ts` (mission snapshots):
* - incremental refresh since last_applied_at
* - bootstraps unseen (userId, agentId) pairs on first sighting
* - delete-tombstone on op='delete'
*
* NOT done on the server:
* - decrypting `systemPrompt` / `memory`. Those fields travel as
* `enc:1:…` strings from the webapp's encryption pipeline; the
* server treats ciphertext as "do not inject" and only uses
* plaintext fields (name, role, avatar, policy, state, budgets).
* If the user wants the model to see their memory/system-prompt,
* the foreground runner handles that mission.
*/
import type { Sql } from './connection';
import { withUser } from './connection';
export interface ServerAgent {
id: string;
userId: string;
name: string;
avatar?: string;
role: string;
/** May be an encrypted blob (`enc:1:`) or plaintext depending on
* the user's crypto setup. The runner checks for the prefix and
* only injects plaintext. */
systemPrompt?: string;
memory?: string;
state: 'active' | 'paused' | 'archived';
maxConcurrentMissions: number;
maxTokensPerDay?: number;
deletedAt?: string;
}
interface SnapshotRow {
user_id: string;
agent_id: string;
record: Record<string, unknown>;
last_applied_at: Date;
}
interface ChangeRow {
user_id: string;
record_id: string;
op: string;
data: Record<string, unknown> | null;
field_timestamps: Record<string, string> | null;
created_at: Date;
}
export interface AgentRefreshStats {
usersProcessed: number;
newSnapshots: number;
updatedSnapshots: number;
rowsApplied: number;
}
/**
* Fold every new `sync_changes` row (appId='ai', table='agents') into
* its matching agent snapshot. Returns counts for logging.
*/
export async function refreshAgentSnapshots(sql: Sql): Promise<AgentRefreshStats> {
const stats: AgentRefreshStats = {
usersProcessed: 0,
newSnapshots: 0,
updatedSnapshots: 0,
rowsApplied: 0,
};
const due = await sql<{ user_id: string; agent_id: string; since: Date | null }[]>`
WITH latest AS (
SELECT user_id, record_id AS agent_id, MAX(created_at) AS max_ts
FROM sync_changes
WHERE app_id = 'ai' AND table_name = 'agents'
GROUP BY user_id, record_id
)
SELECT l.user_id, l.agent_id,
COALESCE(s.last_applied_at, 'epoch'::timestamptz) AS since
FROM latest l
LEFT JOIN mana_ai.agent_snapshots s
ON s.user_id = l.user_id AND s.agent_id = l.agent_id
WHERE l.max_ts > COALESCE(s.last_applied_at, 'epoch'::timestamptz)
`;
if (due.length === 0) return stats;
for (const entry of due) {
const outcome = await refreshOne(sql, entry.user_id, entry.agent_id, entry.since);
stats.rowsApplied += outcome.rowsApplied;
if (outcome.created) stats.newSnapshots++;
else stats.updatedSnapshots++;
}
stats.usersProcessed = new Set(due.map((d) => d.user_id)).size;
return stats;
}
async function refreshOne(
sql: Sql,
userId: string,
agentId: string,
since: Date | null
): Promise<{ rowsApplied: number; created: boolean }> {
const rows = await withUser(
sql,
userId,
async (tx) =>
tx<ChangeRow[]>`
SELECT user_id, record_id, op, data, field_timestamps, created_at
FROM sync_changes
WHERE app_id = 'ai'
AND table_name = 'agents'
AND user_id = ${userId}
AND record_id = ${agentId}
AND created_at > ${since ?? new Date(0)}
ORDER BY created_at ASC
`
);
if (rows.length === 0) return { rowsApplied: 0, created: false };
const existing = await sql<SnapshotRow[]>`
SELECT user_id, agent_id, record, last_applied_at
FROM mana_ai.agent_snapshots
WHERE user_id = ${userId} AND agent_id = ${agentId}
`;
const seed = existing[0];
const syntheticPrefix: ChangeRow[] = seed
? [
{
user_id: userId,
record_id: agentId,
op: 'insert',
data: seed.record,
field_timestamps:
(seed.record.__fieldTimestamps as Record<string, string> | undefined) ?? null,
created_at: seed.last_applied_at,
},
]
: [];
const merged = mergeRaw([...syntheticPrefix, ...rows]);
if (!merged) {
await sql`
DELETE FROM mana_ai.agent_snapshots
WHERE user_id = ${userId} AND agent_id = ${agentId}
`;
return { rowsApplied: rows.length, created: false };
}
const newCursor = rows[rows.length - 1].created_at;
await sql`
INSERT INTO mana_ai.agent_snapshots (user_id, agent_id, record, last_applied_at, updated_at)
VALUES (${userId}, ${agentId}, ${sql.json(merged as never)}, ${newCursor}, now())
ON CONFLICT (user_id, agent_id)
DO UPDATE SET
record = EXCLUDED.record,
last_applied_at = EXCLUDED.last_applied_at,
updated_at = now()
`;
return { rowsApplied: rows.length, created: !seed };
}
/** Field-level LWW reduction over ChangeRow[]. Same semantics as
* mission snapshots' inline merge — agents don't have cadence / due
* filters to apply here. Exported for unit tests only. */
export function mergeRaw(rows: readonly ChangeRow[]): Record<string, unknown> | null {
let record: Record<string, unknown> | null = null;
let ft: Record<string, string> = {};
for (const row of rows) {
if (row.op === 'delete') return null;
if (!record) {
record = row.data ? { id: row.record_id, ...row.data } : { id: row.record_id };
ft = { ...(row.field_timestamps ?? {}) };
continue;
}
if (!row.data) continue;
const rowFT = row.field_timestamps ?? {};
for (const [k, v] of Object.entries(row.data)) {
const serverTime = rowFT[k] ?? row.created_at.toISOString();
const localTime = ft[k] ?? '';
if (serverTime >= localTime) {
record[k] = v;
ft[k] = serverTime;
}
}
}
if (record && (record.deletedAt as string | undefined)) return null;
if (record) record.__fieldTimestamps = ft;
return record;
}
// ─── Loaders ─────────────────────────────────────────────────
/** Load one agent by id for the given user. Returns null for
* unknown/deleted. */
export async function loadAgent(
sql: Sql,
userId: string,
agentId: string
): Promise<ServerAgent | null> {
const rows = await sql<SnapshotRow[]>`
SELECT user_id, agent_id, record, last_applied_at
FROM mana_ai.agent_snapshots
WHERE user_id = ${userId} AND agent_id = ${agentId}
`;
if (rows.length === 0) return null;
return toServerAgent(rows[0]);
}
/** Load every non-deleted agent for the user. Used by the tick to
* build a per-user map for fast lookup. */
export async function loadActiveAgents(sql: Sql, userId: string): Promise<ServerAgent[]> {
const rows = await sql<SnapshotRow[]>`
SELECT user_id, agent_id, record, last_applied_at
FROM mana_ai.agent_snapshots
WHERE user_id = ${userId}
AND (record->>'deletedAt') IS NULL
`;
return rows.map(toServerAgent);
}
function toServerAgent(row: SnapshotRow): ServerAgent {
const r = row.record;
return {
id: String(r.id),
userId: row.user_id,
name: String(r.name ?? 'Unnamed'),
avatar: typeof r.avatar === 'string' ? r.avatar : undefined,
role: String(r.role ?? ''),
systemPrompt: typeof r.systemPrompt === 'string' ? r.systemPrompt : undefined,
memory: typeof r.memory === 'string' ? r.memory : undefined,
state: (r.state as ServerAgent['state']) ?? 'active',
maxConcurrentMissions: Number(r.maxConcurrentMissions ?? 1),
maxTokensPerDay: typeof r.maxTokensPerDay === 'number' ? r.maxTokensPerDay : undefined,
deletedAt: typeof r.deletedAt === 'string' ? r.deletedAt : undefined,
};
}

View file

@ -17,7 +17,13 @@
import type { Sql } from './connection';
import { withUser } from './connection';
import { makeSystemActor, SYSTEM_MISSION_RUNNER } from '@mana/shared-ai';
import {
makeAgentActor,
makeSystemActor,
SYSTEM_MISSION_RUNNER,
LEGACY_AI_PRINCIPAL,
type Actor,
} from '@mana/shared-ai';
import type { AiPlanOutput, MissionIteration, PlanStep } from '@mana/shared-ai';
export interface AppendIterationInput {
@ -34,16 +40,50 @@ export interface AppendIterationInput {
/** When the write happened used as the per-field updatedAt stamp
* and the sync_changes.created_at fallback. */
nowIso: string;
/** Owning Agent context. When provided, the iteration row is
* attributed to an `ai` actor with the agent's principalId + name;
* the Workbench timeline on the webapp then groups this writer's
* output under the right agent. When absent, falls back to the
* legacy system-actor (Phase 1 shape) so pre-Phase-3 missions
* still work. */
agent?: {
id: string;
name: string;
};
/** Iteration id required when `agent` is set, passed through to
* the Actor so the revert path can group this write by iteration. */
iterationId?: string;
/** Rationale for the Actor defaults to the iteration summary or
* the mission objective. */
rationale?: string;
}
/** Actor blob stamped on the sync_changes row. JSON string already
* we pass it as `json.RawMessage` equivalent through pgx. Uses the
* identity-aware Actor shape from @mana/shared-ai so the webapp's
* timeline can group + filter server-produced iterations alongside
* agent/user writes. Phase 2 will switch this to a per-agent actor
* when the mission carries `agentId`. */
function systemActorJson(): string {
return JSON.stringify(makeSystemActor(SYSTEM_MISSION_RUNNER));
/** Build the actor blob stamped on the sync_changes row. When the
* mission has an owning agent we attribute the write to that agent;
* otherwise we fall back to the mission-runner system actor so legacy
* missions (no agentId yet) still produce a valid Actor shape. */
function buildActor(input: AppendIterationInput): Actor {
if (input.agent && input.iterationId) {
return makeAgentActor({
agentId: input.agent.id,
displayName: input.agent.name,
missionId: input.missionId,
iterationId: input.iterationId,
rationale: input.rationale ?? '',
});
}
// Legacy path — no agent context. Still identity-aware via the
// system principal, just without agent grouping.
if (input.iterationId) {
return makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: input.missionId,
iterationId: input.iterationId,
rationale: input.rationale ?? '',
});
}
return makeSystemActor(SYSTEM_MISSION_RUNNER);
}
export async function appendServerIteration(sql: Sql, input: AppendIterationInput): Promise<void> {
@ -70,7 +110,7 @@ export async function appendServerIteration(sql: Sql, input: AppendIterationInpu
// happens correctly at runtime.
const dataJson = data as unknown;
const ftJson = fieldTimestamps as unknown;
const actorJson = JSON.parse(systemActorJson()) as unknown;
const actorJson = buildActor(input) as unknown;
await withUser(sql, userId, async (tx) => {
await tx`

View file

@ -89,4 +89,33 @@ export async function migrate(sql: Sql): Promise<void> {
END IF;
END $$
`;
// ─── Agent snapshots (Multi-Agent Workbench, Phase 3) ────────
// Mirrors mission_snapshots: a materialized LWW-merged view of the
// agents table from sync_changes. Runner loads agents per-user
// per-tick from here instead of replaying the event log each time.
// systemPrompt + memory stay as they arrived (encrypted strings for
// most users); the runner opts to skip injecting ciphertext rather
// than requiring a per-agent Grant.
await sql`
CREATE TABLE IF NOT EXISTS mana_ai.agent_snapshots (
user_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
record JSONB NOT NULL,
last_applied_at TIMESTAMPTZ NOT NULL DEFAULT 'epoch',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, agent_id)
)
`;
await sql`
CREATE INDEX IF NOT EXISTS idx_agent_snapshots_user
ON mana_ai.agent_snapshots (user_id, last_applied_at)
`;
await sql`
CREATE INDEX IF NOT EXISTS idx_agent_snapshots_state
ON mana_ai.agent_snapshots ((record->>'state'))
WHERE record->>'state' = 'active'
`;
}

View file

@ -31,6 +31,11 @@ export interface ServerMission {
/** Present iff the mission has a Key-Grant attached enables
* decryption of encrypted-table inputs during this tick. */
grant?: MissionGrant;
/** Owning Agent id (Multi-Agent Workbench). Undefined on legacy
* missions; tick loads the agent when present to inject
* systemPrompt + memory into the Planner prompt and to stamp the
* server-iteration actor with the correct principal. */
agentId?: string;
}
interface ChangeRow {
@ -80,6 +85,7 @@ export async function listDueMissions(sql: Sql, now: string): Promise<ServerMiss
cadence: record.cadence,
iterations: Array.isArray(record.iterations) ? record.iterations : [],
grant: (record.grant ?? undefined) as MissionGrant | undefined,
agentId: typeof record.agentId === 'string' ? record.agentId : undefined,
}));
}
@ -147,6 +153,7 @@ export function mergeAndFilter(
cadence: record.cadence,
iterations: Array.isArray(record.iterations) ? record.iterations : [],
grant: (record.grant ?? undefined) as MissionGrant | undefined,
agentId: typeof record.agentId === 'string' ? record.agentId : undefined,
});
}
return missions;

View file

@ -123,3 +123,25 @@ export const grantSkipsTotal = new Counter({
labelNames: ['reason'] as const,
registers: [register],
});
// ── Multi-Agent Workbench (Phase 3) ───────────────────────
/**
* Per-mission decision the tick took with respect to the owning agent.
* Possible `decision` values:
* - `ran` mission processed normally under the agent
* - `skipped-paused` agent.state === 'paused'
* - `skipped-archived` agent.state === 'archived'
* - `skipped-concurrency` agent's maxConcurrentMissions already hit
* this tick; retried next tick
*
* Missions without an owning agent (legacy, pre-Phase-2) don't produce
* this metric that's why `mana_ai_plans_written_back_total` stays
* the ground-truth "did we run" counter.
*/
export const agentDecisionsTotal = new Counter({
name: 'mana_ai_agent_decisions_total',
help: 'Per-mission decision the tick made against the owning Agent.',
labelNames: ['decision'] as const,
registers: [register],
});