mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-27 18:34:58 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a141f24f69 | |||
| bb1db4e35b | |||
| 5684cfd69b |
@@ -15,6 +15,8 @@ export interface RunnerConfig {
|
|||||||
groupName: string;
|
groupName: string;
|
||||||
agentGroupId: string;
|
agentGroupId: string;
|
||||||
maxMessagesPerPrompt: number;
|
maxMessagesPerPrompt: number;
|
||||||
|
/** Idle window in ms after which the poll loop exits cleanly. 0 = disabled. */
|
||||||
|
idleTimeoutMs: number;
|
||||||
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
@@ -44,6 +46,7 @@ export function loadConfig(): RunnerConfig {
|
|||||||
groupName: (raw.groupName as string) || '',
|
groupName: (raw.groupName as string) || '',
|
||||||
agentGroupId: (raw.agentGroupId as string) || '',
|
agentGroupId: (raw.agentGroupId as string) || '',
|
||||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||||
|
idleTimeoutMs: (raw.idleTimeoutMs as number) || 0,
|
||||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||||
model: (raw.model as string) || undefined,
|
model: (raw.model as string) || undefined,
|
||||||
effort: (raw.effort as string) || undefined,
|
effort: (raw.effort as string) || undefined,
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Idle-timeout guard — the machinery that lets ephemeral sessions exit
|
||||||
|
* cleanly instead of riding until host-sweep's absolute ceiling.
|
||||||
|
*
|
||||||
|
* Behavior leg: the idle tracker with an injected clock (markActivity /
|
||||||
|
* shouldExit semantics, including the hasProcessedAtLeastOne gate and the
|
||||||
|
* idleTimeoutMs <= 0 disable).
|
||||||
|
*
|
||||||
|
* AST legs (runPollLoop is an infinite loop — not invocable in a test):
|
||||||
|
* - runPollLoop destructures idleTimeoutMs from loadConfig() (the
|
||||||
|
* destructure may carry other keys; this only pins idleTimeoutMs);
|
||||||
|
* - the empty-poll branch exits via process.exit(0) gated on
|
||||||
|
* idle.shouldExit();
|
||||||
|
* - idle.markActivity() runs after the batch-completion
|
||||||
|
* markCompleted(processingIds) so the idle window restarts per batch;
|
||||||
|
* - the processQuery call site threads idleTimeoutMs as the 5th argument;
|
||||||
|
* - the 'result' event arm calls query.end() gated on
|
||||||
|
* `idleTimeoutMs > 0 && !hasUnwrapped` — never unconditionally, or the
|
||||||
|
* unwrapped-output re-send nudge would be cut off mid-stream;
|
||||||
|
* - loadConfig()'s returned literal carries the idleTimeoutMs field
|
||||||
|
* (RunnerConfig's type is covered by the typecheck leg).
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
import { createIdleTracker } from './idle-tracker.js';
|
||||||
|
|
||||||
|
describe('idle tracker behavior', () => {
|
||||||
|
it('never exits before the first processed batch, regardless of elapsed time', () => {
|
||||||
|
let clock = 0;
|
||||||
|
const tracker = createIdleTracker(1000, () => clock);
|
||||||
|
clock = 1_000_000;
|
||||||
|
expect(tracker.shouldExit()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits only after the idle window elapses past the last activity', () => {
|
||||||
|
let clock = 0;
|
||||||
|
const tracker = createIdleTracker(1000, () => clock);
|
||||||
|
|
||||||
|
tracker.markActivity(); // first batch completes at t=0
|
||||||
|
clock = 900;
|
||||||
|
expect(tracker.shouldExit()).toBe(false);
|
||||||
|
clock = 1001;
|
||||||
|
expect(tracker.shouldExit()).toBe(true);
|
||||||
|
|
||||||
|
// New activity re-arms the window.
|
||||||
|
tracker.markActivity();
|
||||||
|
clock = 1900;
|
||||||
|
expect(tracker.shouldExit()).toBe(false);
|
||||||
|
clock = 2002;
|
||||||
|
expect(tracker.shouldExit()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idleTimeoutMs <= 0 disables idle exit entirely', () => {
|
||||||
|
let clock = 0;
|
||||||
|
const tracker = createIdleTracker(0, () => clock);
|
||||||
|
tracker.markActivity();
|
||||||
|
clock = 10_000_000;
|
||||||
|
expect(tracker.shouldExit()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── AST legs ──
|
||||||
|
|
||||||
|
function parse(file: string): ts.SourceFile {
|
||||||
|
const source = fs.readFileSync(path.join(import.meta.dir, file), 'utf8');
|
||||||
|
return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAll<T extends ts.Node>(root: ts.Node, pred: (n: ts.Node) => n is T): T[] {
|
||||||
|
const out: T[] = [];
|
||||||
|
const visit = (n: ts.Node): void => {
|
||||||
|
if (pred(n)) out.push(n);
|
||||||
|
n.forEachChild(visit);
|
||||||
|
};
|
||||||
|
visit(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAncestor(node: ts.Node, pred: (n: ts.Node) => boolean): boolean {
|
||||||
|
let cur: ts.Node | undefined = node.parent;
|
||||||
|
while (cur) {
|
||||||
|
if (pred(cur)) return true;
|
||||||
|
cur = cur.parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('poll-loop.ts idle wiring', () => {
|
||||||
|
const sf = parse('poll-loop.ts');
|
||||||
|
const runPollLoop = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'runPollLoop');
|
||||||
|
|
||||||
|
it('destructures idleTimeoutMs from loadConfig()', () => {
|
||||||
|
const decls = findAll(runPollLoop!, ts.isVariableDeclaration).filter(
|
||||||
|
(d) =>
|
||||||
|
d.initializer !== undefined &&
|
||||||
|
ts.isCallExpression(d.initializer) &&
|
||||||
|
ts.isIdentifier(d.initializer.expression) &&
|
||||||
|
d.initializer.expression.text === 'loadConfig' &&
|
||||||
|
ts.isObjectBindingPattern(d.name),
|
||||||
|
);
|
||||||
|
expect(decls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const hasKey = decls.some((d) =>
|
||||||
|
(d.name as ts.ObjectBindingPattern).elements.some(
|
||||||
|
(e) => ts.isIdentifier(e.name) && e.name.text === 'idleTimeoutMs',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(hasKey).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the empty-poll branch exits 0 gated on idle.shouldExit()', () => {
|
||||||
|
const exits = findAll(runPollLoop!, ts.isCallExpression).filter(
|
||||||
|
(c) =>
|
||||||
|
ts.isPropertyAccessExpression(c.expression) &&
|
||||||
|
c.expression.getText(sf) === 'process.exit' &&
|
||||||
|
c.arguments[0]?.getText(sf) === '0',
|
||||||
|
);
|
||||||
|
const gated = exits.filter((c) =>
|
||||||
|
hasAncestor(
|
||||||
|
c,
|
||||||
|
(n) => ts.isIfStatement(n) && n.expression.getText(sf).replace(/\s+/g, '') === 'idle.shouldExit()',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(gated.length).toBe(1);
|
||||||
|
// And the gate itself sits inside the messages.length === 0 branch.
|
||||||
|
expect(
|
||||||
|
hasAncestor(gated[0], (n) => ts.isIfStatement(n) && n.expression.getText(sf).includes('messages.length === 0')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks activity after markCompleted so the idle window restarts per batch', () => {
|
||||||
|
const marks = findAll(runPollLoop!, ts.isCallExpression).filter(
|
||||||
|
(c) => c.expression.getText(sf).replace(/\s+/g, '') === 'idle.markActivity',
|
||||||
|
);
|
||||||
|
expect(marks.length).toBe(1);
|
||||||
|
// The batch-completion call is markCompleted(processingIds) — the others
|
||||||
|
// handle command/skip bookkeeping and must not arm the idle window.
|
||||||
|
const completed = findAll(runPollLoop!, ts.isCallExpression).filter(
|
||||||
|
(c) =>
|
||||||
|
ts.isIdentifier(c.expression) &&
|
||||||
|
c.expression.text === 'markCompleted' &&
|
||||||
|
c.arguments[0]?.getText(sf) === 'processingIds',
|
||||||
|
);
|
||||||
|
expect(completed.length).toBe(1);
|
||||||
|
expect(marks[0].getStart(sf)).toBeGreaterThan(completed[0].getStart(sf));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads idleTimeoutMs as processQuery's 5th argument", () => {
|
||||||
|
const calls = findAll(runPollLoop!, ts.isCallExpression).filter(
|
||||||
|
(c) => ts.isIdentifier(c.expression) && c.expression.text === 'processQuery',
|
||||||
|
);
|
||||||
|
expect(calls.length).toBe(1);
|
||||||
|
expect(calls[0].arguments.length).toBe(5);
|
||||||
|
expect(calls[0].arguments[4].getText(sf)).toBe('idleTimeoutMs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the 'result' event arm ends the stream gated on idleTimeoutMs > 0 && !hasUnwrapped", () => {
|
||||||
|
const processQuery = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'processQuery');
|
||||||
|
expect(processQuery).toBeDefined();
|
||||||
|
const ends = findAll(processQuery!, ts.isCallExpression).filter(
|
||||||
|
(c) => c.expression.getText(sf).replace(/\s+/g, '') === 'query.end',
|
||||||
|
);
|
||||||
|
// The !hasUnwrapped half of the gate is load-bearing: an unconditional
|
||||||
|
// (or idleTimeoutMs-only) end would close the stream right after the
|
||||||
|
// unwrapped-output nudge was pushed, stranding the re-sent response.
|
||||||
|
const gated = ends.filter((c) =>
|
||||||
|
hasAncestor(
|
||||||
|
c,
|
||||||
|
(n) =>
|
||||||
|
ts.isIfStatement(n) && n.expression.getText(sf).replace(/\s+/g, '') === 'idleTimeoutMs>0&&!hasUnwrapped',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(gated.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config.ts idle wiring', () => {
|
||||||
|
const sf = parse('config.ts');
|
||||||
|
|
||||||
|
it('loadConfig returns an idleTimeoutMs field', () => {
|
||||||
|
const loadConfig = findAll(sf, ts.isFunctionDeclaration).find((f) => f.name?.text === 'loadConfig');
|
||||||
|
expect(loadConfig).toBeDefined();
|
||||||
|
const props = findAll(loadConfig!, ts.isPropertyAssignment).filter(
|
||||||
|
(p) => ts.isIdentifier(p.name) && p.name.text === 'idleTimeoutMs',
|
||||||
|
);
|
||||||
|
expect(props.length).toBe(1);
|
||||||
|
// Reads the raw container.json key with a 0 default (0 = disabled).
|
||||||
|
expect(props[0].initializer.getText(sf).replace(/\s+/g, '')).toContain('raw.idleTimeoutMs');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Idle-exit tracker for ephemeral sessions.
|
||||||
|
*
|
||||||
|
* The poll loop creates one tracker per run and makes two one-line calls:
|
||||||
|
*
|
||||||
|
* - `markActivity()` after a batch completes — records the last time the
|
||||||
|
* agent did real work and arms the tracker (an agent that never processed
|
||||||
|
* anything must not idle-exit before its first trigger arrives).
|
||||||
|
* - `shouldExit()` in the empty-poll branch — true once idleTimeoutMs > 0,
|
||||||
|
* at least one batch has been processed, and the idle window has elapsed.
|
||||||
|
*
|
||||||
|
* `idleTimeoutMs` comes from the group's container.json (RunnerConfig),
|
||||||
|
* materialized from the `container_configs.idle_timeout_ms` column. A value
|
||||||
|
* of 0 (the default) disables idle exit entirely — the container then rides
|
||||||
|
* until host-sweep's absolute ceiling, exactly as before this tracker existed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IdleTracker {
|
||||||
|
/** Record activity: arms the tracker and resets the idle window. */
|
||||||
|
markActivity(): void;
|
||||||
|
/** True when the session has been idle past the timeout and may exit 0. */
|
||||||
|
shouldExit(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIdleTracker(idleTimeoutMs: number, now: () => number = Date.now): IdleTracker {
|
||||||
|
let lastActivityAt = now();
|
||||||
|
let hasProcessedAtLeastOne = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
markActivity(): void {
|
||||||
|
lastActivityAt = now();
|
||||||
|
hasProcessedAtLeastOne = true;
|
||||||
|
},
|
||||||
|
shouldExit(): boolean {
|
||||||
|
if (idleTimeoutMs <= 0) return false;
|
||||||
|
if (!hasProcessedAtLeastOne) return false;
|
||||||
|
return now() - lastActivityAt > idleTimeoutMs;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { writeMessageOut } from './db/messages-out.js';
|
|||||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { createIdleTracker } from './idle-tracker.js';
|
||||||
import {
|
import {
|
||||||
formatMessages,
|
formatMessages,
|
||||||
extractRouting,
|
extractRouting,
|
||||||
@@ -104,6 +106,12 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
// This lets the new container re-process those messages.
|
// This lets the new container re-process those messages.
|
||||||
clearStaleProcessingAcks();
|
clearStaleProcessingAcks();
|
||||||
|
|
||||||
|
// Idle exit: when the group's container config sets idle_timeout_ms, an
|
||||||
|
// idle container exits 0 after the window elapses instead of riding until
|
||||||
|
// host-sweep's absolute ceiling kills it. Unset/0 = disabled (default).
|
||||||
|
const { idleTimeoutMs } = loadConfig();
|
||||||
|
const idle = createIdleTracker(idleTimeoutMs);
|
||||||
|
|
||||||
let pollCount = 0;
|
let pollCount = 0;
|
||||||
let isFirstPoll = true;
|
let isFirstPoll = true;
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -118,6 +126,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
|
if (idle.shouldExit()) {
|
||||||
|
log(`Idle timeout (${idleTimeoutMs}ms) — exiting`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
await sleep(POLL_INTERVAL_MS);
|
await sleep(POLL_INTERVAL_MS);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -232,7 +244,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||||
setCurrentInReplyTo(routing.inReplyTo);
|
setCurrentInReplyTo(routing.inReplyTo);
|
||||||
try {
|
try {
|
||||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
const result = await processQuery(query, routing, processingIds, config.providerName, idleTimeoutMs);
|
||||||
if (result.continuation && result.continuation !== continuation) {
|
if (result.continuation && result.continuation !== continuation) {
|
||||||
continuation = result.continuation;
|
continuation = result.continuation;
|
||||||
setContinuation(config.providerName, continuation);
|
setContinuation(config.providerName, continuation);
|
||||||
@@ -266,6 +278,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
// Ensure completed even if processQuery ended without a result event
|
// Ensure completed even if processQuery ended without a result event
|
||||||
// (e.g. stream closed unexpectedly).
|
// (e.g. stream closed unexpectedly).
|
||||||
markCompleted(processingIds);
|
markCompleted(processingIds);
|
||||||
|
idle.markActivity();
|
||||||
log(`Completed ${ids.length} message(s)`);
|
log(`Completed ${ids.length} message(s)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,6 +326,7 @@ async function processQuery(
|
|||||||
routing: RoutingContext,
|
routing: RoutingContext,
|
||||||
initialBatchIds: string[],
|
initialBatchIds: string[],
|
||||||
providerName: string,
|
providerName: string,
|
||||||
|
idleTimeoutMs: number = 0,
|
||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
let queryContinuation: string | undefined;
|
let queryContinuation: string | undefined;
|
||||||
let done = false;
|
let done = false;
|
||||||
@@ -454,8 +468,9 @@ async function processQuery(
|
|||||||
// (send_message) mid-turn, or the message may not need a response
|
// (send_message) mid-turn, or the message may not need a response
|
||||||
// at all — either way the turn is finished.
|
// at all — either way the turn is finished.
|
||||||
markCompleted(initialBatchIds);
|
markCompleted(initialBatchIds);
|
||||||
|
let hasUnwrapped = false;
|
||||||
if (event.text) {
|
if (event.text) {
|
||||||
const { hasUnwrapped } = dispatchResultText(event.text, routing);
|
({ hasUnwrapped } = dispatchResultText(event.text, routing));
|
||||||
if (hasUnwrapped && !unwrappedNudged) {
|
if (hasUnwrapped && !unwrappedNudged) {
|
||||||
unwrappedNudged = true;
|
unwrappedNudged = true;
|
||||||
const destinations = getAllDestinations();
|
const destinations = getAllDestinations();
|
||||||
@@ -468,6 +483,13 @@ async function processQuery(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// When idleTimeoutMs is set, end the stream once the turn completes
|
||||||
|
// so the outer loop can evaluate the idle window. Skipped while the
|
||||||
|
// turn's output was unwrapped — the re-send nudge pushed above needs
|
||||||
|
// the stream to stay open for the corrected response.
|
||||||
|
if (idleTimeoutMs > 0 && !hasUnwrapped) {
|
||||||
|
query.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ CREATE TABLE container_configs (
|
|||||||
image_tag TEXT,
|
image_tag TEXT,
|
||||||
assistant_name TEXT,
|
assistant_name TEXT,
|
||||||
max_messages_per_prompt INTEGER,
|
max_messages_per_prompt INTEGER,
|
||||||
|
idle_timeout_ms INTEGER, -- idle-exit window (ms); NULL/0 = disabled
|
||||||
skills TEXT NOT NULL DEFAULT '"all"',
|
skills TEXT NOT NULL DEFAULT '"all"',
|
||||||
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||||
packages_apt TEXT NOT NULL DEFAULT '[]',
|
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||||
@@ -344,6 +345,7 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
|
|||||||
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||||
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||||
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||||
|
| 016 | `016-container-idle-timeout.ts` | `ALTER TABLE container_configs ADD COLUMN idle_timeout_ms` |
|
||||||
|
|
||||||
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function backfillContainerConfigs(): void {
|
|||||||
image_tag: legacy.imageTag ?? null,
|
image_tag: legacy.imageTag ?? null,
|
||||||
assistant_name: legacy.assistantName ?? null,
|
assistant_name: legacy.assistantName ?? null,
|
||||||
max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
|
max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
|
||||||
|
idle_timeout_ms: null,
|
||||||
skills: JSON.stringify(legacy.skills ?? 'all'),
|
skills: JSON.stringify(legacy.skills ?? 'all'),
|
||||||
mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
|
mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
|
||||||
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
|
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
|
|||||||
image_tag: row.image_tag,
|
image_tag: row.image_tag,
|
||||||
assistant_name: row.assistant_name,
|
assistant_name: row.assistant_name,
|
||||||
max_messages_per_prompt: row.max_messages_per_prompt,
|
max_messages_per_prompt: row.max_messages_per_prompt,
|
||||||
|
idle_timeout_ms: row.idle_timeout_ms,
|
||||||
skills: JSON.parse(row.skills),
|
skills: JSON.parse(row.skills),
|
||||||
mcp_servers: JSON.parse(row.mcp_servers),
|
mcp_servers: JSON.parse(row.mcp_servers),
|
||||||
packages_apt: JSON.parse(row.packages_apt),
|
packages_apt: JSON.parse(row.packages_apt),
|
||||||
@@ -213,7 +214,7 @@ registerResource({
|
|||||||
access: 'approval',
|
access: 'approval',
|
||||||
description:
|
description:
|
||||||
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
|
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
|
||||||
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
|
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --idle-timeout-ms, --cli-scope.',
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
const id = args.id as string;
|
const id = args.id as string;
|
||||||
if (!id) throw new Error('--id is required');
|
if (!id) throw new Error('--id is required');
|
||||||
@@ -223,7 +224,14 @@ registerResource({
|
|||||||
const updates: Partial<
|
const updates: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
ContainerConfigRow,
|
ContainerConfigRow,
|
||||||
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
|
| 'provider'
|
||||||
|
| 'model'
|
||||||
|
| 'effort'
|
||||||
|
| 'image_tag'
|
||||||
|
| 'assistant_name'
|
||||||
|
| 'max_messages_per_prompt'
|
||||||
|
| 'idle_timeout_ms'
|
||||||
|
| 'cli_scope'
|
||||||
>
|
>
|
||||||
> = {};
|
> = {};
|
||||||
if (args.provider !== undefined) updates.provider = args.provider as string;
|
if (args.provider !== undefined) updates.provider = args.provider as string;
|
||||||
@@ -233,6 +241,7 @@ registerResource({
|
|||||||
if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
|
if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
|
||||||
if (args.max_messages_per_prompt !== undefined)
|
if (args.max_messages_per_prompt !== undefined)
|
||||||
updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
|
updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
|
||||||
|
if (args.idle_timeout_ms !== undefined) updates.idle_timeout_ms = Number(args.idle_timeout_ms);
|
||||||
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
|
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
|
||||||
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
|
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
|
||||||
if (!['disabled', 'group', 'global'].includes(scope)) {
|
if (!['disabled', 'group', 'global'].includes(scope)) {
|
||||||
@@ -243,7 +252,7 @@ registerResource({
|
|||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
|
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --idle-timeout-ms, --cli-scope',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* idle_timeout_ms threading — migration 016 column → `ContainerConfigRow` →
|
||||||
|
* `configFromDb()` → `materializeContainerJson()` → `container.json`.
|
||||||
|
*
|
||||||
|
* The default leg is load-bearing: a NULL column must keep `idleTimeoutMs`
|
||||||
|
* out of container.json entirely, so groups that never set the value get
|
||||||
|
* today's behavior byte-identical (the container-side loadConfig then
|
||||||
|
* defaults to 0 = idle exit disabled).
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('./config.js', async () => {
|
||||||
|
const actual = await vi.importActual('./config.js');
|
||||||
|
return { ...actual, GROUPS_DIR: '/tmp/nanoclaw-test-container-config/groups' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const TEST_DIR = '/tmp/nanoclaw-test-container-config';
|
||||||
|
const GROUPS_DIR = path.join(TEST_DIR, 'groups');
|
||||||
|
|
||||||
|
import { initTestDb, closeDb, runMigrations } from './db/index.js';
|
||||||
|
import { createAgentGroup, getAgentGroup } from './db/agent-groups.js';
|
||||||
|
import { ensureContainerConfig, getContainerConfig, updateContainerConfigScalars } from './db/container-configs.js';
|
||||||
|
import { configFromDb, materializeContainerJson } from './container-config.js';
|
||||||
|
|
||||||
|
const GID = 'ag-idle';
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('container config idle_timeout_ms threading', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(GROUPS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const db = initTestDb();
|
||||||
|
runMigrations(db);
|
||||||
|
|
||||||
|
createAgentGroup({ id: GID, name: 'idle-group', folder: 'idle-group', agent_provider: null, created_at: now() });
|
||||||
|
ensureContainerConfig(GID);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeDb();
|
||||||
|
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateContainerConfigScalars persists idle_timeout_ms and configFromDb threads it', () => {
|
||||||
|
updateContainerConfigScalars(GID, { idle_timeout_ms: 300000 });
|
||||||
|
|
||||||
|
const row = getContainerConfig(GID)!;
|
||||||
|
expect(row.idle_timeout_ms).toBe(300000);
|
||||||
|
|
||||||
|
const config = configFromDb(row, getAgentGroup(GID)!);
|
||||||
|
expect(config.idleTimeoutMs).toBe(300000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializeContainerJson writes idleTimeoutMs into container.json', () => {
|
||||||
|
updateContainerConfigScalars(GID, { idle_timeout_ms: 300000 });
|
||||||
|
|
||||||
|
const config = materializeContainerJson(GID);
|
||||||
|
expect(config.idleTimeoutMs).toBe(300000);
|
||||||
|
|
||||||
|
const written = JSON.parse(fs.readFileSync(path.join(GROUPS_DIR, 'idle-group', 'container.json'), 'utf8'));
|
||||||
|
expect(written.idleTimeoutMs).toBe(300000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NULL column (the default) keeps idleTimeoutMs out of container.json — feature off', () => {
|
||||||
|
const row = getContainerConfig(GID)!;
|
||||||
|
expect(row.idle_timeout_ms).toBeNull();
|
||||||
|
|
||||||
|
const config = configFromDb(row, getAgentGroup(GID)!);
|
||||||
|
expect(config.idleTimeoutMs).toBeUndefined();
|
||||||
|
|
||||||
|
materializeContainerJson(GID);
|
||||||
|
const written = JSON.parse(fs.readFileSync(path.join(GROUPS_DIR, 'idle-group', 'container.json'), 'utf8'));
|
||||||
|
// JSON.stringify drops undefined — the key must be absent, not null/0.
|
||||||
|
expect('idleTimeoutMs' in written).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,8 @@ export interface ContainerConfig {
|
|||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
agentGroupId?: string;
|
agentGroupId?: string;
|
||||||
maxMessagesPerPrompt?: number;
|
maxMessagesPerPrompt?: number;
|
||||||
|
/** Idle window in ms after which an idle container exits cleanly. Unset/0 = disabled. */
|
||||||
|
idleTimeoutMs?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,7 @@ export function configFromDb(row: ContainerConfigRow, group: AgentGroup): Contai
|
|||||||
assistantName: row.assistant_name ?? group.name,
|
assistantName: row.assistant_name ?? group.name,
|
||||||
agentGroupId: group.id,
|
agentGroupId: group.id,
|
||||||
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
|
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
|
||||||
|
idleTimeoutMs: row.idle_timeout_ms ?? undefined,
|
||||||
model: row.model ?? undefined,
|
model: row.model ?? undefined,
|
||||||
effort: row.effort ?? undefined,
|
effort: row.effort ?? undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const SCALAR_COLUMNS = new Set([
|
|||||||
'image_tag',
|
'image_tag',
|
||||||
'assistant_name',
|
'assistant_name',
|
||||||
'max_messages_per_prompt',
|
'max_messages_per_prompt',
|
||||||
|
'idle_timeout_ms',
|
||||||
'cli_scope',
|
'cli_scope',
|
||||||
]);
|
]);
|
||||||
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
|
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
|
||||||
@@ -55,7 +56,14 @@ export function updateContainerConfigScalars(
|
|||||||
updates: Partial<
|
updates: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
ContainerConfigRow,
|
ContainerConfigRow,
|
||||||
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
|
| 'provider'
|
||||||
|
| 'model'
|
||||||
|
| 'effort'
|
||||||
|
| 'image_tag'
|
||||||
|
| 'assistant_name'
|
||||||
|
| 'max_messages_per_prompt'
|
||||||
|
| 'idle_timeout_ms'
|
||||||
|
| 'cli_scope'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import type { Migration } from './index.js';
|
||||||
|
|
||||||
|
export const migration017: Migration = {
|
||||||
|
version: 17,
|
||||||
|
name: 'container-idle-timeout',
|
||||||
|
up(db: Database.Database) {
|
||||||
|
// Idle-exit window in ms for the agent container. NULL (the default) or 0
|
||||||
|
// disables idle exit — existing groups keep today's behavior, where an
|
||||||
|
// idle container rides until host-sweep's absolute ceiling kills it.
|
||||||
|
db.prepare('ALTER TABLE container_configs ADD COLUMN idle_timeout_ms INTEGER').run();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { migration013 } from './013-approval-render-metadata.js';
|
|||||||
import { migration014 } from './014-container-configs.js';
|
import { migration014 } from './014-container-configs.js';
|
||||||
import { migration015 } from './015-cli-scope.js';
|
import { migration015 } from './015-cli-scope.js';
|
||||||
import { migration016 } from './016-messaging-group-instance.js';
|
import { migration016 } from './016-messaging-group-instance.js';
|
||||||
|
import { migration017 } from './017-container-idle-timeout.js';
|
||||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export const migrations: Migration[] = [
|
|||||||
migration014,
|
migration014,
|
||||||
migration015,
|
migration015,
|
||||||
migration016,
|
migration016,
|
||||||
|
migration017,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
|
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface ContainerConfigRow {
|
|||||||
image_tag: string | null;
|
image_tag: string | null;
|
||||||
assistant_name: string | null;
|
assistant_name: string | null;
|
||||||
max_messages_per_prompt: number | null;
|
max_messages_per_prompt: number | null;
|
||||||
|
idle_timeout_ms: number | null; // idle-exit window (ms); NULL/0 = disabled
|
||||||
skills: string; // JSON: '"all"' | '["skill1","skill2"]'
|
skills: string; // JSON: '"all"' | '["skill1","skill2"]'
|
||||||
mcp_servers: string; // JSON: Record<string, McpServerConfig>
|
mcp_servers: string; // JSON: Record<string, McpServerConfig>
|
||||||
packages_apt: string; // JSON: string[]
|
packages_apt: string; // JSON: string[]
|
||||||
|
|||||||
Reference in New Issue
Block a user