Compare commits

...

1 Commits

Author SHA1 Message Date
Omri Maya 8357b1ca62 feat(tasks): one-door delivery — send_message is the only path out of a task session
A task fire has no chat attached, so delivery gets exactly one door: the
send_message tool with an explicit destination. Final-text <message to>
blocks are inert in task sessions; the fire's final text is auto-appended
to the series run log (tasks/<id>.md) instead — exactly once per run
(skipped when the agent already wrote its own append-log line that fire).
Inert blocks are stripped from auto-appended lines and logged as
[undelivered → …] so a misaddressed send is visible in the log rather than
silently gone. A same-turn nudge catches an agent that ends a fire with
inert blocks; the previous echo-suppression heuristics are removed with
the ambiguity they served. Chat sessions are unchanged.

The host now stamps is_task on session_routing at session creation —
the container reads that instead of sniffing the thread-id prefix.

BREAKING: task prompts that relied on final-text <message to> delivery
must call send_message(to=...); tasks created with the standard scaffold
already instruct this. Rebuild the container image and restart so runners
pick up the new poll loop (see CHANGELOG migration note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:28:42 +03:00
20 changed files with 708 additions and 91 deletions
+2 -1
View File
@@ -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^(n1) 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^(n1) 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.
+58 -17
View File
@@ -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 };
}
+3 -4
View File
@@ -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
+15 -2
View File
@@ -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>`.
+93 -19
View File
@@ -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);
});
});
+9 -20
View File
@@ -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
View File
@@ -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
);
`;
+50 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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',
+21 -1
View File
@@ -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 -13
View File
@@ -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> {
+32
View File
@@ -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 };
}
+5
View 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();