mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8357b1ca62 |
+2
-1
@@ -4,7 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **One-door delivery in task sessions.** A task fire delivers ONLY via the `send_message` tool, and `to` is required there (no default destination). Final-text `<message to>` blocks are inert in task sessions, and the fire's final text is auto-appended to the series run log (`tasks/<id>.md`) unless the agent already ran `ncl tasks append-log` that fire — a run is logged exactly once, and the double-send/zero-send ambiguity of having two delivery paths is gone (the previous echo-suppression heuristics are removed). Chat sessions are unchanged. **Migration:** rebuild the agent container image (`./container/build.sh`) and restart so containers pick up the new runner; task prompts that relied on `<message to>` blocks must switch to `send_message(to=...)` — existing tasks created with the standard scaffold already instruct this.
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
|
||||
@@ -142,21 +142,62 @@ export function getUndeliveredMessages(): MessageOutRow[] {
|
||||
.all() as MessageOutRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a deliberate send with this exact destination + text already exists
|
||||
* (an MCP send_message row from the current turn). Used by the task-fire
|
||||
* final-text dispatcher to drop the turn-final <message> echo of a send the
|
||||
* agent already made — the dedup happens where the duplication originates.
|
||||
*/
|
||||
export function hasIdenticalSend(platformId: string, channelType: string, text: string): boolean {
|
||||
const row = getOutboundDb()
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages_out
|
||||
WHERE platform_id = $platform_id AND channel_type = $channel_type
|
||||
AND (in_reply_to IS NULL OR in_reply_to = '')
|
||||
AND json_extract(content, '$.text') = $text
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get({ $platform_id: platformId, $channel_type: channelType, $text: text });
|
||||
return row != null;
|
||||
/** Highest seq across both session DBs — marks "now" for since-queries. */
|
||||
export function maxSeq(): number {
|
||||
const maxOut = (getOutboundDb().prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number })
|
||||
.m;
|
||||
const maxIn = (getInboundDb().prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
|
||||
return Math.max(maxOut, maxIn);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the agent invoked `ncl tasks append-log` after `sinceSeq` (the ncl
|
||||
* binary writes each CLI call as a system cli_request row in messages_out).
|
||||
* Guards the task-fire auto-log: an explicit append-log this fire suppresses
|
||||
* the runner's final-text auto-append, so a run is logged exactly once.
|
||||
*/
|
||||
export function hasAppendLogRequestSince(sinceSeq: number): boolean {
|
||||
// LIKE, not equality: a positional invocation (`ncl tasks append-log "..."`)
|
||||
// arrives dash-joined as 'tasks-append-log-<msg…>' — the host dispatcher
|
||||
// resolves the tail as the positional id.
|
||||
const requests = getOutboundDb()
|
||||
.prepare(
|
||||
`SELECT content FROM messages_out
|
||||
WHERE seq > $since AND kind = 'system'
|
||||
AND json_extract(content, '$.action') = 'cli_request'
|
||||
AND json_extract(content, '$.command') LIKE 'tasks-append-log%'`,
|
||||
)
|
||||
.all({ $since: sinceSeq }) as Array<{ content: string }>;
|
||||
if (requests.length === 0) return false;
|
||||
|
||||
// Only a request that SUCCEEDED counts as "this fire was logged". Correlate
|
||||
// each request with its host response in inbound messages_in (content carries
|
||||
// the requestId + response frame). A request with no readable response yet
|
||||
// still suppresses — the common success case is the response landing after
|
||||
// the turn ends, and double-logging is worse than a rare missed line. Only a
|
||||
// DEFINITIVE failure (frame ok:false) does not suppress.
|
||||
const inbound = getInboundDb();
|
||||
for (const { content } of requests) {
|
||||
let requestId: string | null = null;
|
||||
try {
|
||||
requestId = (JSON.parse(content) as { requestId?: string }).requestId ?? null;
|
||||
} catch {
|
||||
// Malformed request row — can't correlate; suppress conservatively.
|
||||
return true;
|
||||
}
|
||||
if (!requestId) return true;
|
||||
|
||||
const resp = inbound
|
||||
.prepare('SELECT content FROM messages_in WHERE content LIKE $pat ORDER BY seq DESC LIMIT 1')
|
||||
.get({ $pat: `%"requestId":"${requestId}"%` }) as { content: string } | undefined;
|
||||
if (!resp) return true; // pending response → treat as logged
|
||||
try {
|
||||
const frame = (JSON.parse(resp.content) as { frame?: { ok?: boolean } }).frame;
|
||||
if (frame?.ok !== false) return true; // ok:true (or unreadable frame) → logged
|
||||
} catch {
|
||||
return true; // unparseable response — suppress conservatively
|
||||
}
|
||||
// frame.ok === false → this request definitively failed; check the next one.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -14,17 +14,28 @@ export interface SessionRouting {
|
||||
channel_type: string | null;
|
||||
platform_id: string | null;
|
||||
thread_id: string | null;
|
||||
/** 1 = host stamped this session as a task-series session. */
|
||||
is_task: 0 | 1;
|
||||
}
|
||||
|
||||
export function getSessionRouting(): SessionRouting {
|
||||
const db = getInboundDb();
|
||||
try {
|
||||
const row = db
|
||||
.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1')
|
||||
.prepare('SELECT channel_type, platform_id, thread_id, is_task FROM session_routing WHERE id = 1')
|
||||
.get() as SessionRouting | undefined;
|
||||
if (row) return row;
|
||||
} catch {
|
||||
// Table may not exist on an older session DB — fall through to defaults
|
||||
// is_task column may not exist yet (host predates the stamp) — retry
|
||||
// without it and derive task-ness from the thread prefix.
|
||||
try {
|
||||
const row = db
|
||||
.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1')
|
||||
.get() as Omit<SessionRouting, 'is_task'> | undefined;
|
||||
if (row) return { ...row, is_task: row.thread_id?.startsWith('system:tasks') ? 1 : 0 };
|
||||
} catch {
|
||||
// Table may not exist on an older session DB — fall through to defaults
|
||||
}
|
||||
}
|
||||
return { channel_type: null, platform_id: null, thread_id: null };
|
||||
return { channel_type: null, platform_id: null, thread_id: null, is_task: 0 };
|
||||
}
|
||||
|
||||
@@ -98,10 +98,9 @@ export interface RoutingContext {
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
inReplyTo: string | null;
|
||||
/** Batch is a task fire — explicit `<message to>` sends must NOT inherit
|
||||
* inReplyTo (the series id), or the host's task-fire suppression drops
|
||||
* them as turn-final echoes: zero delivery. Deliberate sends are
|
||||
* in_reply_to-null, same as the out-of-process MCP send_message path. */
|
||||
/** Batch is a task fire. One-door delivery: only the send_message tool
|
||||
* delivers from a task session; final-text `<message to>` blocks are inert
|
||||
* and the final text auto-appends to the series run log. */
|
||||
taskFire: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Additional resources (available under `global` scope only): messaging-groups, wi
|
||||
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
|
||||
- **Checking who's in your group** — `ncl members list`.
|
||||
- **Seeing your destinations** — `ncl destinations list`.
|
||||
- **Scheduling work** — `ncl tasks create`, then `ncl tasks list/get/update/cancel/pause/resume/delete`; `ncl tasks run <id>` fires one extra run now (testing) without changing the schedule. At the end of each task run, `ncl tasks append-log --msg "…"` to record what happened (host-timestamped, not a message).
|
||||
- **Scheduling work** — `ncl tasks create`, then `ncl tasks list/get/update/cancel/pause/resume/delete`; `ncl tasks run <id>` fires one extra run now (testing) without changing the schedule. Each task run auto-logs its final text to the run log; `ncl tasks append-log --msg "…"` is for extra mid-run notes (host-timestamped, not a message).
|
||||
- **Answering questions about the system** — query `ncl` rather than guessing.
|
||||
|
||||
### Access rules
|
||||
|
||||
@@ -53,8 +53,20 @@ function resolveRouting(
|
||||
to: string | undefined,
|
||||
): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } {
|
||||
if (!to) {
|
||||
// Default: reply to whatever thread/channel this session is bound to.
|
||||
const session = getSessionRouting();
|
||||
// Task sessions have no origin chat — "reply in place" is meaningless and
|
||||
// the single-destination shortcut below would silently pick an arbitrary
|
||||
// target. One-door rule: a task fire must name its destination.
|
||||
// Driven by the host-stamped is_task flag (session_routing); the thread
|
||||
// prefix check is only a fallback for hosts that predate the stamp.
|
||||
// (Prefix mirrors TASKS_SYSTEM_THREAD_ID on the host, src/db/sessions.ts —
|
||||
// the two runtimes share no modules.)
|
||||
if (session.is_task === 1 || session.thread_id?.startsWith('system:tasks')) {
|
||||
return {
|
||||
error: `This is a task session — pass to=<destination> explicitly. Options: ${destinationList()}`,
|
||||
};
|
||||
}
|
||||
// Default: reply to whatever thread/channel this session is bound to.
|
||||
if (session.channel_type && session.platform_id) {
|
||||
return {
|
||||
channel_type: session.channel_type,
|
||||
@@ -95,7 +107,8 @@ function resolveRouting(
|
||||
export const sendMessage: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_message',
|
||||
description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
||||
description:
|
||||
'Send a message to a named destination. If you have only one destination, you can omit `to` — except in task sessions, where `to` is always required (this tool is the ONLY delivery path there; final text is never sent).',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Task scheduling (`ncl tasks`)
|
||||
|
||||
Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session, so when it fires you must choose a destination explicitly with `<message to="name">...</message>` or `send_message({ to: "name", ... })`.
|
||||
Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session. When it fires, the ONLY way to message anyone is `send_message({ to: "name", ... })` with an explicit destination — final text and `<message>` blocks are not delivered from task sessions; the fire's final text is recorded in the task's run log instead.
|
||||
|
||||
Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"` → `sales-briefing-a25c`); without it ids are `t-<hex>`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, markScriptSkipped, type MessageInRow } from './db/messages-in.js';
|
||||
import { hasIdenticalSend, writeMessageOut } from './db/messages-out.js';
|
||||
import { hasAppendLogRequestSince, maxSeq, writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
@@ -340,6 +340,9 @@ export async function processQuery(
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
let unwrappedNudged = false;
|
||||
// Once-per-turn guard for the task-fire "<message> block was not delivered"
|
||||
// nudge — mirrors unwrappedNudged for chat turns.
|
||||
let taskBlockNudged = false;
|
||||
// Prompt queue for the exchange hook — each result event consumes the
|
||||
// oldest unanswered prompt, except a wrapping-retry result, which answers
|
||||
// the same prompt again. Unused (and unmaintained) when the provider
|
||||
@@ -423,6 +426,7 @@ export async function processQuery(
|
||||
const prompt = formatMessages(keep);
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
unwrappedNudged = false;
|
||||
taskBlockNudged = false;
|
||||
query.push(prompt);
|
||||
archivePrompts.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
@@ -464,6 +468,10 @@ export async function processQuery(
|
||||
})();
|
||||
}, ACTIVE_POLL_INTERVAL_MS);
|
||||
|
||||
// Seq watermark before the agent runs — anything after this is "this fire"
|
||||
// for the append-log exactly-once guard.
|
||||
const turnStartSeq = maxSeq();
|
||||
|
||||
try {
|
||||
for await (const event of query.events) {
|
||||
handleEvent(event, routing);
|
||||
@@ -487,8 +495,15 @@ export async function processQuery(
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
|
||||
if (sent === 0 && event.isError === true) {
|
||||
const { sent, hasUnwrapped, taskBlocks } = dispatchResultText(event.text, routing);
|
||||
const willRetryTaskBlocks = shouldNudgeTaskBlocks(routing.taskFire === true, taskBlocks, taskBlockNudged);
|
||||
// One-door task delivery: the final text becomes the run log entry
|
||||
// (guarded — an explicit append-log this fire wins). Errors included:
|
||||
// a failed fire's text belongs in the run log, not in a chat.
|
||||
// When we nudge on inert <message> blocks, DEFER the append to the
|
||||
// retry's result so the fire logs exactly once.
|
||||
if (routing.taskFire && !willRetryTaskBlocks) autoAppendTaskLog(event.text, turnStartSeq);
|
||||
if (sent === 0 && event.isError === true && !routing.taskFire) {
|
||||
// Non-retryable error turn (e.g. a 403 billing_error) with no
|
||||
// <message> envelope: deliver the notice instead of dropping it as
|
||||
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
|
||||
@@ -520,9 +535,20 @@ export async function processQuery(
|
||||
`Please re-send your response with the correct wrapping.</system>`,
|
||||
);
|
||||
}
|
||||
// The wrapping-retry result answers the SAME user prompt — keep it
|
||||
// queued so the retry archives against it, not the nudge text.
|
||||
if (!willRetryWrapping) archivePrompts.shift();
|
||||
if (willRetryTaskBlocks) {
|
||||
taskBlockNudged = true;
|
||||
const names = getAllDestinations()
|
||||
.map((d) => d.name)
|
||||
.join(', ');
|
||||
query.push(
|
||||
`<system>Your <message> block was NOT delivered — task sessions deliver only via the send_message tool. ` +
|
||||
`Re-send now with send_message({to: "<name>", ...}). Your destinations: ${names}.</system>`,
|
||||
);
|
||||
}
|
||||
// A retry result (wrapping or task-block nudge) answers the SAME
|
||||
// user prompt — keep it queued so the retry archives against it,
|
||||
// not the nudge text.
|
||||
if (!willRetryWrapping && !willRetryTaskBlocks) archivePrompts.shift();
|
||||
}
|
||||
} else {
|
||||
archivePrompts.shift();
|
||||
@@ -605,11 +631,17 @@ function deliverErrorResult(text: string, routing: RoutingContext): void {
|
||||
* The agent must always wrap output in <message to="name">...</message>
|
||||
* blocks, even with a single destination. Bare text is scratchpad only.
|
||||
*/
|
||||
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
|
||||
export function dispatchResultText(
|
||||
text: string,
|
||||
routing: RoutingContext,
|
||||
): { sent: number; hasUnwrapped: boolean; taskBlocks: number } {
|
||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
let sent = 0;
|
||||
// <message to> blocks left inert in a task fire — drives the same-turn
|
||||
// "use send_message" nudge in processQuery.
|
||||
let taskBlocks = 0;
|
||||
let lastIndex = 0;
|
||||
const scratchpadParts: string[] = [];
|
||||
|
||||
@@ -621,6 +653,16 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
|
||||
const body = match[2].trim();
|
||||
lastIndex = MESSAGE_RE.lastIndex;
|
||||
|
||||
// One-door delivery in task sessions: only the send_message tool delivers.
|
||||
// A final-text <message to> block here is either an echo of a tool send the
|
||||
// agent already made (the double-delivery class) or a send down the wrong
|
||||
// path — never deliver it, keep it visible in the scratchpad/run log.
|
||||
if (routing.taskFire) {
|
||||
log(`Task fire: <message to="${toName}"> block not delivered — task sessions send only via send_message`);
|
||||
scratchpadParts.push(`[not delivered — task sessions send only via the send_message tool; to="${toName}"] ${body}`);
|
||||
taskBlocks++;
|
||||
continue;
|
||||
}
|
||||
const dest = findByName(toName);
|
||||
if (!dest) {
|
||||
log(`Unknown destination in <message to="${toName}">, dropping block`);
|
||||
@@ -640,25 +682,57 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
|
||||
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
||||
}
|
||||
|
||||
const hasUnwrapped = sent === 0 && !!scratchpad;
|
||||
// In a task fire, plain final text is the NORMAL ending (it becomes the run
|
||||
// log) — never treat it as an undelivered reply or nudge the agent to wrap it.
|
||||
const hasUnwrapped = !routing.taskFire && sent === 0 && !!scratchpad;
|
||||
if (hasUnwrapped) {
|
||||
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
|
||||
}
|
||||
return { sent, hasUnwrapped };
|
||||
return { sent, hasUnwrapped, taskBlocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Should this task-fire result get the same-turn "your <message> block was
|
||||
* not delivered — use send_message" nudge? True at most once per turn
|
||||
* (mirrors the unwrappedNudged flag for chat turns). While true, the run-log
|
||||
* auto-append is DEFERRED to the retry's result so the fire logs exactly once.
|
||||
*/
|
||||
export function shouldNudgeTaskBlocks(taskFire: boolean, taskBlocks: number, alreadyNudged: boolean): boolean {
|
||||
return taskFire && taskBlocks > 0 && !alreadyNudged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task fires: the final text IS the run log entry, unless the agent already
|
||||
* logged this fire explicitly via `ncl tasks append-log` (exactly-once guard —
|
||||
* old tasks whose baked-in prompt still mandates append-log don't double-log).
|
||||
* Written as a `task_log` outbound row; the host appends it to the series'
|
||||
* tasks/<id>.md with its usual timestamp stamp. Never delivered to anyone.
|
||||
*/
|
||||
export function autoAppendTaskLog(text: string, turnStartSeq: number): void {
|
||||
// Run-log hygiene: an inert <message to> block never belongs in the log as
|
||||
// raw XML — replace each with its inner text, marked undelivered, so the
|
||||
// log stays readable prose.
|
||||
const prose = text.replace(
|
||||
/<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g,
|
||||
(_m, to: string, body: string) => `[undelivered → ${to}] ${body.trim()}`,
|
||||
);
|
||||
const line = stripInternalTags(prose).replace(/\s+/g, ' ').trim().slice(0, 500);
|
||||
if (!line) return;
|
||||
if (hasAppendLogRequestSince(turnStartSeq)) {
|
||||
log('Task fire already logged via append-log — skipping final-text auto-log');
|
||||
return;
|
||||
}
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'task_log',
|
||||
content: JSON.stringify({ text: line }),
|
||||
});
|
||||
log('Task fire run log auto-appended from final text');
|
||||
}
|
||||
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
||||
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
||||
// Task fires: an explicitly-addressed final-text block is either the echo of
|
||||
// an MCP send the agent already made this turn (drop it HERE, where the
|
||||
// duplication originates) or the agent's only deliberate send (write it
|
||||
// in_reply_to-null like the MCP path, or the host's task-fire suppression
|
||||
// would discard it — zero delivery).
|
||||
if (routing.taskFire && hasIdenticalSend(platformId, channelType, body)) {
|
||||
log(`Dropping turn-final echo of an already-sent task message to ${dest.name}`);
|
||||
return;
|
||||
}
|
||||
// Resolve thread_id per-destination from the most recent inbound message
|
||||
// that came from this same channel+platform. In agent-shared sessions,
|
||||
// different destinations have different thread contexts — using a single
|
||||
@@ -666,7 +740,7 @@ function sendToDestination(dest: DestinationEntry, body: string, routing: Routin
|
||||
const destRouting = resolveDestinationThread(channelType, platformId);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: destRouting?.inReplyTo ?? (routing.taskFire ? null : routing.inReplyTo),
|
||||
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: platformId,
|
||||
channel_type: channelType,
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* One-door delivery in task sessions.
|
||||
*
|
||||
* Wiring under test (each leg goes red if its integration is deleted):
|
||||
* 1. send_message with no `to` ERRORS in a task session (session_routing
|
||||
* thread system:tasks:*) instead of falling back to a default target.
|
||||
* 2. Final-text `<message to>` blocks are inert in a task fire — no
|
||||
* outbound chat row, no "undelivered" nudge state.
|
||||
* 3. The fire's final text auto-appends as a `task_log` outbound row,
|
||||
* EXCEPT when the agent already ran `ncl tasks append-log` this fire.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
|
||||
import { getUndeliveredMessages, hasAppendLogRequestSince, maxSeq, writeMessageOut } from './db/messages-out.js';
|
||||
import { sendMessage } from './mcp-tools/core.js';
|
||||
import { autoAppendTaskLog, dispatchResultText, shouldNudgeTaskBlocks } from './poll-loop.js';
|
||||
import type { RoutingContext } from './formatter.js';
|
||||
|
||||
function seedSessionRouting(threadId: string | null, isTask?: 0 | 1): void {
|
||||
const db = getInboundDb();
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS session_routing (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
channel_type TEXT, platform_id TEXT, thread_id TEXT,
|
||||
is_task INTEGER NOT NULL DEFAULT 0
|
||||
)`);
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO session_routing (id, channel_type, platform_id, thread_id, is_task) VALUES (1, ?, ?, ?, ?)',
|
||||
).run(
|
||||
threadId ? null : 'telegram',
|
||||
threadId ? null : 'telegram:123',
|
||||
threadId,
|
||||
isTask ?? (threadId?.startsWith('system:tasks') ? 1 : 0),
|
||||
);
|
||||
}
|
||||
|
||||
function seedDestination(): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('family', 'Family', 'channel', 'telegram', 'telegram:99', NULL)`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
const taskRouting: RoutingContext = {
|
||||
platformId: 'ag-1',
|
||||
channelType: 'agent',
|
||||
threadId: 'system:tasks:daily-digest-a1b2',
|
||||
inReplyTo: 'fire-1',
|
||||
taskFire: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
seedDestination();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message in a task session', () => {
|
||||
it('errors without `to` — no default-destination fallback', async () => {
|
||||
seedSessionRouting('system:tasks:daily-digest-a1b2');
|
||||
|
||||
const res = (await sendMessage.handler({ text: 'hello' })) as { isError?: boolean; content: { text: string }[] };
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(res.content[0].text).toContain('task session');
|
||||
expect(getUndeliveredMessages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('the host-stamped is_task flag alone drives the gate (thread_id NULL)', async () => {
|
||||
// No thread prefix to sniff — proves the flag, not the magic string,
|
||||
// is what makes the session a task session.
|
||||
seedSessionRouting(null, 1);
|
||||
|
||||
const res = (await sendMessage.handler({ text: 'hello' })) as { isError?: boolean; content: { text: string }[] };
|
||||
|
||||
expect(res.isError).toBe(true);
|
||||
expect(res.content[0].text).toContain('task session');
|
||||
expect(getUndeliveredMessages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('delivers normally with an explicit `to`', async () => {
|
||||
seedSessionRouting('system:tasks:daily-digest-a1b2');
|
||||
|
||||
await sendMessage.handler({ to: 'family', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].platform_id).toBe('telegram:99');
|
||||
});
|
||||
|
||||
it('chat sessions keep the reply-in-place default', async () => {
|
||||
seedSessionRouting(null); // normal chat routing row
|
||||
|
||||
await sendMessage.handler({ text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].platform_id).toBe('telegram:123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('final-text <message to> blocks in a task fire', () => {
|
||||
it('are inert — no outbound row, no undelivered flag, counted for the nudge', () => {
|
||||
const { sent, hasUnwrapped, taskBlocks } = dispatchResultText(
|
||||
'<message to="family">digest is ready</message>',
|
||||
taskRouting,
|
||||
);
|
||||
|
||||
expect(sent).toBe(0);
|
||||
expect(hasUnwrapped).toBe(false); // plain text is the normal task ending — never the wrap nudge
|
||||
expect(taskBlocks).toBe(1); // but the inert block IS flagged for the task nudge
|
||||
expect(getUndeliveredMessages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still deliver in non-task sessions', () => {
|
||||
const { sent, taskBlocks } = dispatchResultText('<message to="family">hi</message>', {
|
||||
...taskRouting,
|
||||
taskFire: false,
|
||||
});
|
||||
|
||||
expect(sent).toBe(1);
|
||||
expect(taskBlocks).toBe(0);
|
||||
expect(getUndeliveredMessages()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('nudge fires once per turn, and only in task fires with blocks', () => {
|
||||
expect(shouldNudgeTaskBlocks(true, 1, false)).toBe(true);
|
||||
expect(shouldNudgeTaskBlocks(true, 1, true)).toBe(false); // already nudged this turn
|
||||
expect(shouldNudgeTaskBlocks(true, 0, false)).toBe(false); // plain text is the normal ending
|
||||
expect(shouldNudgeTaskBlocks(false, 1, false)).toBe(false); // chat turns use the wrap nudge
|
||||
});
|
||||
|
||||
it('run-log auto-append happens exactly once across a nudged turn', () => {
|
||||
const start = maxSeq();
|
||||
let nudged = false;
|
||||
|
||||
// Result 1: the agent ended the fire with an inert <message> block.
|
||||
const first = dispatchResultText('<message to="family">digest</message>', taskRouting);
|
||||
const willRetry = shouldNudgeTaskBlocks(true, first.taskBlocks, nudged);
|
||||
expect(willRetry).toBe(true);
|
||||
if (!willRetry) autoAppendTaskLog('<message to="family">digest</message>', start);
|
||||
nudged = true;
|
||||
|
||||
// Result 2 (post-nudge retry): plain final text → this one becomes the log.
|
||||
const second = dispatchResultText('Sent the digest to family via send_message.', taskRouting);
|
||||
const willRetry2 = shouldNudgeTaskBlocks(true, second.taskBlocks, nudged);
|
||||
expect(willRetry2).toBe(false);
|
||||
if (!willRetry2) autoAppendTaskLog('Sent the digest to family via send_message.', start);
|
||||
|
||||
const rows = getOutboundDb().prepare("SELECT content FROM messages_out WHERE kind = 'task_log'").all() as {
|
||||
content: string;
|
||||
}[];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(JSON.parse(rows[0].content).text).toBe('Sent the digest to family via send_message.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-fire run-log auto-append', () => {
|
||||
it('writes a task_log row from the final text', () => {
|
||||
const start = maxSeq();
|
||||
|
||||
autoAppendTaskLog('Checked the\nfeeds — nothing new.', start);
|
||||
|
||||
const rows = getOutboundDb().prepare("SELECT kind, content FROM messages_out WHERE kind = 'task_log'").all() as {
|
||||
kind: string;
|
||||
content: string;
|
||||
}[];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(JSON.parse(rows[0].content).text).toBe('Checked the feeds — nothing new.'); // whitespace collapsed
|
||||
});
|
||||
|
||||
it('strips <message to> blocks — logs their inner text marked undelivered, never raw XML', () => {
|
||||
const start = maxSeq();
|
||||
|
||||
autoAppendTaskLog('Digest done. <message to="family">3 new posts today</message> See you tomorrow.', start);
|
||||
|
||||
const rows = getOutboundDb().prepare("SELECT content FROM messages_out WHERE kind = 'task_log'").all() as {
|
||||
content: string;
|
||||
}[];
|
||||
expect(rows).toHaveLength(1);
|
||||
const line = JSON.parse(rows[0].content).text as string;
|
||||
expect(line).not.toContain('<message');
|
||||
expect(line).toContain('[undelivered → family] 3 new posts today');
|
||||
expect(line).toContain('Digest done.');
|
||||
});
|
||||
|
||||
it('is suppressed when the agent ran append-log this fire (exactly-once)', () => {
|
||||
const start = maxSeq();
|
||||
// The ncl binary writes each CLI call as a cli_request system row.
|
||||
writeMessageOut({
|
||||
id: 'cli-1',
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'cli_request', requestId: 'cli-1', command: 'tasks-append-log', args: { msg: 'done' } }),
|
||||
});
|
||||
expect(hasAppendLogRequestSince(start)).toBe(true);
|
||||
|
||||
autoAppendTaskLog('final text', start);
|
||||
|
||||
const rows = getOutboundDb().prepare("SELECT 1 FROM messages_out WHERE kind = 'task_log'").all();
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a positional append-log invocation (dash-joined command) also suppresses', () => {
|
||||
const start = maxSeq();
|
||||
// `ncl tasks append-log "did the thing"` → command 'tasks-append-log-did-the-thing'
|
||||
writeMessageOut({
|
||||
id: 'cli-pos',
|
||||
kind: 'system',
|
||||
content: JSON.stringify({
|
||||
action: 'cli_request',
|
||||
requestId: 'cli-pos',
|
||||
command: 'tasks-append-log-did-the-thing',
|
||||
args: {},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(hasAppendLogRequestSince(start)).toBe(true);
|
||||
|
||||
autoAppendTaskLog('final text', start);
|
||||
expect(getOutboundDb().prepare("SELECT 1 FROM messages_out WHERE kind = 'task_log'").all()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a DEFINITIVELY failed append-log (response ok:false) does not suppress', () => {
|
||||
const start = maxSeq();
|
||||
writeMessageOut({
|
||||
id: 'cli-fail',
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'cli_request', requestId: 'cli-fail', command: 'tasks-append-log', args: {} }),
|
||||
});
|
||||
// Host response frame lands in inbound messages_in with ok:false.
|
||||
getInboundDb()
|
||||
.prepare("INSERT INTO messages_in (id, seq, kind, timestamp, content) VALUES (?, ?, 'system', datetime('now'), ?)")
|
||||
.run(
|
||||
'resp-fail',
|
||||
1000,
|
||||
JSON.stringify({
|
||||
requestId: 'cli-fail',
|
||||
frame: { id: 'cli-fail', ok: false, error: { code: 'invalid-args', message: 'bad series' } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hasAppendLogRequestSince(start)).toBe(false);
|
||||
|
||||
autoAppendTaskLog('final text', start);
|
||||
expect(getOutboundDb().prepare("SELECT 1 FROM messages_out WHERE kind = 'task_log'").all()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an append-log with a still-pending response suppresses (no double-log on the success path)', () => {
|
||||
const start = maxSeq();
|
||||
writeMessageOut({
|
||||
id: 'cli-pending',
|
||||
kind: 'system',
|
||||
content: JSON.stringify({
|
||||
action: 'cli_request',
|
||||
requestId: 'cli-pending',
|
||||
command: 'tasks-append-log',
|
||||
args: {},
|
||||
}),
|
||||
});
|
||||
// No response row in inbound yet.
|
||||
|
||||
expect(hasAppendLogRequestSince(start)).toBe(true);
|
||||
});
|
||||
|
||||
it('append-log from BEFORE the fire does not suppress', () => {
|
||||
writeMessageOut({
|
||||
id: 'cli-old',
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'cli_request', requestId: 'cli-old', command: 'tasks-append-log', args: {} }),
|
||||
});
|
||||
const start = maxSeq(); // watermark taken after the old call
|
||||
|
||||
autoAppendTaskLog('final text', start);
|
||||
|
||||
const rows = getOutboundDb().prepare("SELECT 1 FROM messages_out WHERE kind = 'task_log'").all();
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { inboundDbPath, resolveTaskSession, withInboundDb } from '../../session-manager.js';
|
||||
import { parseZonedToUtc } from '../../timezone.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import { appendRunLog } from '../../modules/scheduling/run-log.js';
|
||||
import { formatTasksTable } from '../format-tasks.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
|
||||
@@ -284,11 +285,9 @@ function createTask(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const { session } = resolveTaskSession(group, id);
|
||||
const promptWithLog =
|
||||
`${prompt}\n\n` +
|
||||
`[A task serves the user two separate ways — do whichever the task above asks for, and ALWAYS the run log:\n` +
|
||||
`• MESSAGE (only if asked): if the task says to report/notify the user, send your result with an EXPLICIT destination — <message to="name">…</message> or send_message({ to: "name", … }). This run has no chat attached: an unaddressed reply is DISCARDED, so the explicit send is the ONLY thing the user receives.\n` +
|
||||
`• RUN LOG (ALWAYS — even if you sent no message and did nothing else this run): after any sends, end the run with:\n` +
|
||||
` ncl tasks append-log --msg "<what you did, and why it mattered>"\n` +
|
||||
` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the UTC time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` +
|
||||
`[Task delivery contract:\n` +
|
||||
`• MESSAGE (only if the task asks you to report/notify): use send_message({ to: "name", … }) with an explicit destination — that tool call is the ONLY thing the user receives. This run has no chat attached: final text and <message> blocks are NOT delivered here.\n` +
|
||||
`• RUN LOG (automatic): your final text is recorded verbatim in tasks/${id}.md — end the run with a concrete work-log line: what you did and WHY (a no-op run still ends with why nothing was needed; name any files you wrote). Not a greeting, not a copy of the message you sent. For extra mid-run notes use \`ncl tasks append-log --msg "…"\` — if you do, your final text is not auto-logged. Do NOT edit tasks/${id}.md by hand; the log never goes to the user.\n` +
|
||||
`Need context from past runs? Read tasks/${id}.md first.]`;
|
||||
|
||||
const created = withInbound(session, (db) => {
|
||||
@@ -329,22 +328,12 @@ function appendTaskLog(
|
||||
}
|
||||
}
|
||||
if (!series) throw new Error('--id is required (no task session to derive it from)');
|
||||
// Charset guard is the security boundary here: blocks path traversal and keeps
|
||||
// the id safe as a filename / thread suffix. Group scope is already enforced by
|
||||
// groupArg (a cli_scope=group caller can only ever resolve its own folder), so a
|
||||
// foreign id at worst writes a stray log under the caller's OWN folder — no leak.
|
||||
if (!/^[a-z0-9-]+$/.test(series)) throw new Error(`invalid task id: ${series}`);
|
||||
if (!group) throw new Error('could not resolve the agent group');
|
||||
|
||||
const ag = getAgentGroup(group);
|
||||
if (!ag) throw new Error(`agent group not found: ${group}`);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const dir = `${GROUPS_DIR}/${ag.folder}/tasks`;
|
||||
const file = `${dir}/${series}.md`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(file, `${timestamp} — ${msg}\n`);
|
||||
return { series, timestamp, path: file, ok: true };
|
||||
// Group scope is enforced by groupArg (a cli_scope=group caller can only
|
||||
// ever resolve its own folder), so a foreign id at worst writes a stray log
|
||||
// under the caller's OWN folder — no leak. appendRunLog guards the charset.
|
||||
return { ...appendRunLog(group, series, msg), ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,7 +648,7 @@ registerResource({
|
||||
'append-log': {
|
||||
access: 'open',
|
||||
description:
|
||||
'Append a one-line run summary to a task run log (tasks/<id>.md).\n\nThe host stamps the UTC timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.',
|
||||
'Append a one-line note to a task run log (tasks/<id>.md).\n\nOptional: a task fire auto-logs its final text, so most runs need no explicit call — use this for mid-run notes (calling it suppresses the final-text auto-log for that fire). The host stamps the UTC timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session.',
|
||||
examples: [
|
||||
`# Inside a task fire (--id auto-derived) — the run's work-log line:\nncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"`,
|
||||
],
|
||||
|
||||
+5
-1
@@ -219,7 +219,11 @@ CREATE TABLE IF NOT EXISTS session_routing (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
channel_type TEXT,
|
||||
platform_id TEXT,
|
||||
thread_id TEXT
|
||||
thread_id TEXT,
|
||||
-- 1 = this session is a task-series session (thread system:tasks:*).
|
||||
-- Stamped by the host so the container never has to sniff the magic
|
||||
-- thread prefix (which fails open when thread_id is absent/renamed).
|
||||
is_task INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
@@ -10,7 +10,13 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { ensureSchema, getInboundSourceSessionId, migrateMessagesInTable, syncProcessingAcks } from './session-db.js';
|
||||
import {
|
||||
ensureSchema,
|
||||
getInboundSourceSessionId,
|
||||
migrateMessagesInTable,
|
||||
syncProcessingAcks,
|
||||
upsertSessionRouting,
|
||||
} from './session-db.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-session-db-test';
|
||||
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
|
||||
@@ -93,6 +99,49 @@ describe('migrateMessagesInTable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('session_routing is_task stamp', () => {
|
||||
it('upsert writes is_task, migrating a legacy table without the column', () => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
// Legacy session_routing (pre-is_task).
|
||||
const db = new Database(DB_PATH);
|
||||
db.exec(`CREATE TABLE session_routing (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
channel_type TEXT, platform_id TEXT, thread_id TEXT
|
||||
)`);
|
||||
|
||||
upsertSessionRouting(db, {
|
||||
channel_type: null,
|
||||
platform_id: null,
|
||||
thread_id: 'system:tasks:daily-abc1',
|
||||
is_task: 1,
|
||||
});
|
||||
// Idempotent re-run flips it back off (host overwrites on every wake).
|
||||
upsertSessionRouting(db, { channel_type: 'telegram', platform_id: 't:1', thread_id: null, is_task: 0 });
|
||||
|
||||
const row = db.prepare('SELECT is_task FROM session_routing WHERE id = 1').get() as { is_task: number };
|
||||
expect(row.is_task).toBe(0);
|
||||
|
||||
upsertSessionRouting(db, { channel_type: null, platform_id: null, thread_id: null, is_task: 1 });
|
||||
const row2 = db.prepare('SELECT is_task FROM session_routing WHERE id = 1').get() as { is_task: number };
|
||||
expect(row2.is_task).toBe(1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('fresh inbound schema already carries is_task', () => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
ensureSchema(DB_PATH, 'inbound');
|
||||
const db = new Database(DB_PATH);
|
||||
const cols = (db.prepare("PRAGMA table_info('session_routing')").all() as Array<{ name: string }>).map(
|
||||
(c) => c.name,
|
||||
);
|
||||
expect(cols).toContain('is_task');
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProcessingAcks — script-skip counter', () => {
|
||||
function freshPair() {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
|
||||
+18
-4
@@ -40,17 +40,31 @@ export function openOutboundDbRw(dbPath: string): Database.Database {
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy, on-open migration for session_routing (mirrors migrateMessagesInTable):
|
||||
* pre-existing session DBs lack the is_task column. Idempotent; no-op when the
|
||||
* table itself is missing (older DBs error on upsert exactly as before).
|
||||
*/
|
||||
export function migrateSessionRoutingTable(db: Database.Database): void {
|
||||
const cols = (db.prepare("PRAGMA table_info('session_routing')").all() as Array<{ name: string }>).map((c) => c.name);
|
||||
if (cols.length > 0 && !cols.includes('is_task')) {
|
||||
db.prepare('ALTER TABLE session_routing ADD COLUMN is_task INTEGER NOT NULL DEFAULT 0').run();
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertSessionRouting(
|
||||
db: Database.Database,
|
||||
routing: { channel_type: string | null; platform_id: string | null; thread_id: string | null },
|
||||
routing: { channel_type: string | null; platform_id: string | null; thread_id: string | null; is_task: 0 | 1 },
|
||||
): void {
|
||||
migrateSessionRoutingTable(db);
|
||||
db.prepare(
|
||||
`INSERT INTO session_routing (id, channel_type, platform_id, thread_id)
|
||||
VALUES (1, @channel_type, @platform_id, @thread_id)
|
||||
`INSERT INTO session_routing (id, channel_type, platform_id, thread_id, is_task)
|
||||
VALUES (1, @channel_type, @platform_id, @thread_id, @is_task)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
channel_type = excluded.channel_type,
|
||||
platform_id = excluded.platform_id,
|
||||
thread_id = excluded.thread_id`,
|
||||
thread_id = excluded.thread_id,
|
||||
is_task = excluded.is_task`,
|
||||
).run(routing);
|
||||
}
|
||||
|
||||
|
||||
+34
-2
@@ -21,7 +21,7 @@ vi.mock('./container-runner.js', () => ({
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-delivery' };
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-delivery', GROUPS_DIR: '/tmp/nanoclaw-test-delivery/groups' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-delivery';
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
createMessagingGroupAgent,
|
||||
} from './db/index.js';
|
||||
import { getDeliveredIds } from './db/session-db.js';
|
||||
import { resolveSession, outboundDbPath, openInboundDb } from './session-manager.js';
|
||||
import { resolveSession, resolveTaskSession, outboundDbPath, openInboundDb } from './session-manager.js';
|
||||
import { deliverSessionMessages, setDeliveryAdapter } from './delivery.js';
|
||||
|
||||
function now(): string {
|
||||
@@ -341,3 +341,35 @@ describe('deliverSessionMessages — permission check', () => {
|
||||
expect(delivered.has('out-unauth')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverSessionMessages — task_log rows (one-door task delivery)', () => {
|
||||
it('appends to the series run log and never calls the adapter', async () => {
|
||||
seedAgentAndChannel();
|
||||
const { session } = resolveTaskSession('ag-1', 'daily-digest-a1b2');
|
||||
|
||||
const db = new Database(outboundDbPath('ag-1', session.id));
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, timestamp, kind, content)
|
||||
VALUES ('log-1', datetime('now'), 'task_log', ?)`,
|
||||
).run(JSON.stringify({ text: 'checked feeds; nothing new' }));
|
||||
db.close();
|
||||
|
||||
const calls: string[] = [];
|
||||
setDeliveryAdapter({
|
||||
async deliver(_c, _p, _t, _k, content) {
|
||||
calls.push(content);
|
||||
return 'pm';
|
||||
},
|
||||
});
|
||||
await deliverSessionMessages(session);
|
||||
|
||||
expect(calls).toHaveLength(0); // a run-log line is not a delivery
|
||||
const logFile = `${TEST_DIR}/groups/test-agent/tasks/daily-digest-a1b2.md`;
|
||||
expect(fs.existsSync(logFile)).toBe(true);
|
||||
const line = fs.readFileSync(logFile, 'utf8').trim();
|
||||
expect(line).toMatch(/^\d{4}-\d{2}-\d{2}T.* — checked feeds; nothing new$/);
|
||||
// Marked delivered — the row is not retried.
|
||||
const delivered = getDeliveredIds(openInboundDb('ag-1', session.id));
|
||||
expect(delivered.has('log-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+26
-1
@@ -9,7 +9,14 @@
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import { getRunningSessions, getActiveSessions, createPendingQuestion } from './db/sessions.js';
|
||||
import {
|
||||
getRunningSessions,
|
||||
getActiveSessions,
|
||||
createPendingQuestion,
|
||||
isTaskThread,
|
||||
TASKS_SYSTEM_THREAD_ID,
|
||||
} from './db/sessions.js';
|
||||
import { appendRunLog } from './modules/scheduling/run-log.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { getMessagingGroup, getMessagingGroupByPlatform } from './db/messaging-groups.js';
|
||||
@@ -260,6 +267,24 @@ async function deliverMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
// Task-fire run log: the runner mirrors a fire's final text here (one-door
|
||||
// delivery — final text never reaches a channel; the send_message tool is
|
||||
// the only delivery path from a task session). Append to the series log,
|
||||
// never deliver. The caller marks it delivered so it isn't retried.
|
||||
if (msg.kind === 'task_log') {
|
||||
if (session.messaging_group_id === null && isTaskThread(session.thread_id) && session.thread_id) {
|
||||
const series = session.thread_id.slice(`${TASKS_SYSTEM_THREAD_ID}:`.length);
|
||||
try {
|
||||
appendRunLog(session.agent_group_id, series, typeof content.text === 'string' ? content.text : '');
|
||||
} catch (err) {
|
||||
log.warn('Failed to append task run log', { id: msg.id, sessionId: session.id, err });
|
||||
}
|
||||
} else {
|
||||
log.warn('task_log row outside a task session — ignoring', { id: msg.id, sessionId: session.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent-to-agent — route to target session via the agent-to-agent module.
|
||||
// Guarded by the channel_type check. If the module isn't installed the
|
||||
// `agent_destinations` table won't exist and `routeAgentMessage`'s permission
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './db/index.js';
|
||||
import {
|
||||
resolveSession,
|
||||
resolveTaskSession,
|
||||
writeSessionMessage,
|
||||
writeSessionRouting,
|
||||
initSessionFolder,
|
||||
@@ -953,6 +954,32 @@ describe('writeSessionRouting', () => {
|
||||
expect(row!.thread_id).toBeNull();
|
||||
});
|
||||
|
||||
it('stamps is_task=1 for a task-series session and 0 for chat sessions', () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
agent_provider: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const { session: taskSession } = resolveTaskSession('ag-1', 'daily-abc1');
|
||||
writeSessionRouting('ag-1', taskSession.id);
|
||||
|
||||
let db = new Database(inboundDbPath('ag-1', taskSession.id));
|
||||
const taskRow = db.prepare('SELECT is_task FROM session_routing WHERE id = 1').get() as { is_task: number };
|
||||
db.close();
|
||||
expect(taskRow.is_task).toBe(1);
|
||||
|
||||
const { session: chatSession } = resolveSession('ag-1', null, null, 'agent-shared');
|
||||
writeSessionRouting('ag-1', chatSession.id);
|
||||
|
||||
db = new Database(inboundDbPath('ag-1', chatSession.id));
|
||||
const chatRow = db.prepare('SELECT is_task FROM session_routing WHERE id = 1').get() as { is_task: number };
|
||||
db.close();
|
||||
expect(chatRow.is_task).toBe(0);
|
||||
});
|
||||
|
||||
it('includes thread_id from per-thread session', () => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
|
||||
@@ -19,9 +19,15 @@ import type { Session } from '../../types.js';
|
||||
// Asia/Tokyo is UTC+9 with no DST: "0 9 * * *" must land at 00:00:00Z sharp.
|
||||
vi.mock('../../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../config.js')>();
|
||||
return { ...actual, TIMEZONE: 'Asia/Tokyo' };
|
||||
return { ...actual, TIMEZONE: 'Asia/Tokyo', GROUPS_DIR: '/tmp/nanoclaw-recurrence-test/groups' };
|
||||
});
|
||||
|
||||
// The auto-pause note goes through the shared appendRunLog helper, which
|
||||
// resolves the group folder from the central DB — mock it to a fixed folder.
|
||||
vi.mock('../../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (id: string) => (id === 'ag-test' ? { id, folder: 'g-test' } : undefined),
|
||||
}));
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-recurrence-test';
|
||||
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
|
||||
|
||||
@@ -190,4 +196,18 @@ describe('handleRecurrence — script-failure backoff (streak derived from faile
|
||||
};
|
||||
expect(original.recurrence).toBeNull(); // not re-cloned next sweep
|
||||
});
|
||||
|
||||
it('writes the auto-pause note into the series run log via the shared appendRunLog', async () => {
|
||||
const db = freshDb();
|
||||
seedFailedStreak(db, 8);
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
// Same file + format appendRunLog owns: groups/<folder>/tasks/<series>.md
|
||||
const logFile = path.join(TEST_DIR, 'groups', 'g-test', 'tasks', 'task-s-0.md');
|
||||
expect(fs.existsSync(logFile)).toBe(true);
|
||||
const content = fs.readFileSync(logFile, 'utf8');
|
||||
expect(content).toContain('auto-paused after 8 consecutive script failures');
|
||||
expect(content).toContain('ncl tasks resume task-s-0');
|
||||
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z — /m); // appendRunLog's stamp
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,17 +11,14 @@
|
||||
* direct dynamic import. When scheduling moves to the modules branch in
|
||||
* PR #8, the install skill re-fills the marker on install.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { TIMEZONE } from '../../config.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { clearRecurrence, getCompletedRecurring, insertRecurrence, trailingFailedRuns } from './db.js';
|
||||
import { appendRunLog } from './run-log.js';
|
||||
|
||||
// Consecutive pre-task-script failures (the series' trailing FAILED runs —
|
||||
// derived from occurrence rows, no stored counter) throttle a broken monitor
|
||||
@@ -38,15 +35,16 @@ export function scriptBackoffMinutes(fails: number): number {
|
||||
}
|
||||
|
||||
/** Host-written line in the series run log — no agent session exists to call
|
||||
* append-log when a script-gated series is auto-paused. Same format as
|
||||
* appendTaskLog (tasks.ts). */
|
||||
* append-log when a script-gated series is auto-paused. Uses the shared
|
||||
* appendRunLog helper (one writer format); appendRunLog throws on a bad
|
||||
* series charset or a missing agent group, and the sweep must not crash
|
||||
* over a log line, so failures are logged and swallowed. */
|
||||
function appendHostTaskNote(agentGroupId: string, seriesId: string, note: string): void {
|
||||
const ag = getAgentGroup(agentGroupId);
|
||||
if (!ag) return;
|
||||
const dir = path.join(GROUPS_DIR, ag.folder, 'tasks');
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(path.join(dir, `${seriesId}.md`), `${timestamp} — ${note}\n`);
|
||||
try {
|
||||
appendRunLog(agentGroupId, seriesId, note);
|
||||
} catch (err) {
|
||||
log.warn('Could not append host task note to run log', { agentGroupId, seriesId, err });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleRecurrence(inDb: Database.Database, session: Session): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Task-series run log — one host-timestamped line per event, at
|
||||
* `<GROUPS_DIR>/<group folder>/tasks/<series>.md`.
|
||||
*
|
||||
* Two writers, one format:
|
||||
* - `ncl tasks append-log` (agent's explicit mid-run/work-log entry)
|
||||
* - the `task_log` outbound row a task fire's final text produces
|
||||
* (container/agent-runner poll-loop auto-append; delivery.ts routes it here)
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import { GROUPS_DIR } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
|
||||
export function appendRunLog(
|
||||
agentGroupId: string,
|
||||
series: string,
|
||||
msg: string,
|
||||
): { series: string; timestamp: string; path: string } {
|
||||
// Charset guard is the security boundary: blocks path traversal and keeps
|
||||
// the id safe as a filename. Callers resolve group scope before this.
|
||||
if (!/^[a-z0-9-]+$/.test(series)) throw new Error(`invalid task id: ${series}`);
|
||||
const ag = getAgentGroup(agentGroupId);
|
||||
if (!ag) throw new Error(`agent group not found: ${agentGroupId}`);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const dir = `${GROUPS_DIR}/${ag.folder}/tasks`;
|
||||
const file = `${dir}/${series}.md`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(file, `${timestamp} — ${msg}\n`);
|
||||
return { series, timestamp, path: file };
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
findSessionByAgentGroup,
|
||||
findSessionForAgent,
|
||||
getSession,
|
||||
isTaskThread,
|
||||
taskThreadId,
|
||||
updateSession,
|
||||
} from './db/sessions.js';
|
||||
@@ -193,6 +194,10 @@ export function writeSessionRouting(agentGroupId: string, sessionId: string): vo
|
||||
channel_type: channelType,
|
||||
platform_id: platformId,
|
||||
thread_id: session.thread_id,
|
||||
// Stamp task-ness explicitly so the container can gate one-door
|
||||
// delivery on a host-asserted flag instead of sniffing the thread
|
||||
// prefix (fail-closed at the source of truth).
|
||||
is_task: isTaskThread(session.thread_id) ? 1 : 0,
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
Reference in New Issue
Block a user