Compare commits

..

8 Commits

Author SHA1 Message Date
glifocat 0683c6ec58 Merge pull request #2536 from glifocat/docs/v2.0.64-release-notes
docs(changelog): add v2.0.64 entry
2026-05-18 18:55:06 +02:00
glifocat 8dbe8c1de8 docs(changelog): add v2.0.64 entry
Documents the fix from #2510 (closes #2465) in user-facing prose
following the RELEASING.md style guide. Single-bullet release —
no rollup opener since this is a clean one-bump cycle.
2026-05-18 12:56:51 +02:00
github-actions[bot] 78bb6cb087 chore: bump version to 2.0.64 2026-05-17 11:50:33 +00:00
gavrielc ce804afb73 Merge pull request #2510 from nanocoai/fix/2465-approval-destinations-inbound-sync
fix(cli): hydrate receiver inbound.db on approval-path destinations add
2026-05-17 14:50:20 +03:00
glifocat 898f4b5f66 Merge branch 'main' into fix/2465-approval-destinations-inbound-sync 2026-05-16 10:49:16 +02:00
glifocat 4b7bfb0a11 fix(cli): hydrate receiver inbound.db on approval-path destinations add/remove
The `destinations add` and `destinations remove` custom ops in the admin
CLI INSERT/DELETE rows in the central `agent_destinations` table, but
did not project the change into running sessions' `inbound.db`. The
agent-runner container reads its destination map from the per-session
projection, so until the next container spawn (`container-runner.ts`
hydrates on every wake), the running agent saw a stale map — explaining
the "dropped: unknown destination" symptom after a fresh `ncl
destinations add` even though the central row was clearly committed.

Same handler runs for both the direct-host path and the approval-execution
path because the `cli_command` approval handler in `dispatch.ts` re-enters
`dispatch()` as `caller: 'host'`, so the fix at the handler level covers
both surfaces.

Helper iterates over `getSessionsByAgentGroup(agentGroupId)` (every
active session for the affected agent), guarded by `hasTable('agent_destinations')`
and a lazy dynamic import of `writeDestinations` to keep the agent-to-agent
module optional. Per-session try/catch keeps one bad session from killing
the whole projection; failures are logged at WARN with session id + error.

Regression test invokes the dispatcher with `caller: 'host'` (the same
re-entry the approval handler uses after admin approves), with two active
sessions on the source agent group, and asserts the `destinations` row
lands in every session's inbound.db after `add` and is cleared after `remove`.

Fixes #2465
2026-05-16 10:47:13 +02:00
glifocat 2ab69269ce Merge pull request #2509 from nanocoai/docs/v2.0.63-release-notes
docs(changelog): align v2.0.63 rollup line with RELEASING.md voice
2026-05-16 10:46:35 +02:00
glifocat 6418dda3da docs(changelog): align v2.0.63 rollup line with RELEASING.md voice
RELEASING.md frames the per-bump release policy as a goal that is cut
manually, not as automation. The v2.0.63 CHANGELOG rollup line still
asserted the stronger claim ("NanoClaw publishes a GitHub Release on
every package.json version bump"), which contradicts the policy doc.
Soften to match RELEASING.md so the two land consistently on main.
2026-05-15 21:04:17 +02:00
5 changed files with 184 additions and 3 deletions
+5 -1
View File
@@ -2,9 +2,13 @@
All notable changes to NanoClaw will be documented in this file.
## [2.0.64] - 2026-05-18
- **`ncl destinations add` and `remove` through the approval flow now reach the receiver immediately.** Approved destinations weren't being projected into the receiving agent's local session state, so a freshly-added destination silently failed at `send_message` with `unknown destination`, and a removed destination stayed resolvable until the next container restart. Both now take effect the moment the approval executes. Direct (non-approval) calls were unaffected.
## [2.0.63] - 2026-05-15
Rollup release covering v2.0.55 through v2.0.63 — everything merged since the v2.0.54 tag. Starting with this release, NanoClaw publishes a GitHub Release on every `package.json` version bump; see [RELEASING.md](RELEASING.md).
Rollup release covering v2.0.55 through v2.0.63 — everything merged since the v2.0.54 tag. Starting with this release, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`; see [RELEASING.md](RELEASING.md).
- [BREAKING] **Service names are now per-install.** On v2 installs the launchd label and systemd unit are slugged to your project root: `com.nanoclaw.<sha1(projectRoot)[:8]>` and `nanoclaw-<slug>.service`. The old `com.nanoclaw` / `nanoclaw.service` names no longer match a real service — update any copy-pasted restart or status commands. Find your install's names with `source setup/lib/install-slug.sh && launchd_label` (macOS) or `systemd_unit` (Linux). The `ncl` transport-error help text and 26 skill files now use the canonical helper-driven pattern; see [setup/lib/install-slug.sh](setup/lib/install-slug.sh).
- **Compaction destination reminder placement fixed.** The reminder injected after SDK auto-compaction now appears at the end of the compaction summary so it isn't stripped during truncation. Replaces the placement shipped in v2.0.54.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.63",
"version": "2.0.64",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+147
View File
@@ -0,0 +1,147 @@
/**
* Regression test for #2465 — approval-path `ncl destinations add/remove`
* must hydrate every active session's `inbound.db` `destinations` table,
* not just the central `agent_destinations` row.
*
* The approval handler in `dispatch.ts` re-enters `dispatch()` with
* `caller: 'host'` after admin approval, so this test invokes dispatch
* with the host caller — same code path as a real approval payload.
*/
import Database from 'better-sqlite3';
import fs from 'fs';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-destinations' };
});
const TEST_DIR = '/tmp/nanoclaw-test-cli-destinations';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
import { createSession } from '../../db/sessions.js';
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
import { dispatch } from '../dispatch.js';
// Side-effect import: registers the `destinations-add` / `destinations-remove` commands.
import './destinations.js';
function now(): string {
return new Date().toISOString();
}
function readSessionDestinations(agentGroupId: string, sessionId: string) {
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
const rows = db.prepare('SELECT name, type, agent_group_id FROM destinations ORDER BY name').all() as Array<{
name: string;
type: string;
agent_group_id: string | null;
}>;
db.close();
return rows;
}
describe('destinations CLI custom ops project to inbound.db (#2465)', () => {
const SOURCE = 'ag-source';
const TARGET = 'ag-target';
const SESSION_A = 'sess-source-1';
const SESSION_B = 'sess-source-2';
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: SOURCE, name: 'source', folder: 'source', agent_provider: null, created_at: now() });
createAgentGroup({ id: TARGET, name: 'target', folder: 'target', agent_provider: null, created_at: now() });
// Two active sessions for the source agent — both must receive the
// projected destination row. Fixing only the "newest" session is a
// common regression shape, so the second session catches that.
for (const sid of [SESSION_A, SESSION_B]) {
createSession({
id: sid,
agent_group_id: SOURCE,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: now(),
});
initSessionFolder(SOURCE, sid);
}
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('add: projects the new destination into every active session inbound.db', async () => {
// Sanity: inbound.db starts with no destinations.
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
// caller: 'host' is what the cli_command approval handler in dispatch.ts
// uses when it re-enters dispatch after admin approval.
const resp = await dispatch(
{
id: 'req-1',
command: 'destinations-add',
args: {
agent_group_id: SOURCE,
local_name: 'helper',
target_type: 'agent',
target_id: TARGET,
},
},
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
for (const sid of [SESSION_A, SESSION_B]) {
const rows = readSessionDestinations(SOURCE, sid);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ name: 'helper', type: 'agent', agent_group_id: TARGET });
}
});
it('remove: clears the destination from every active session inbound.db', async () => {
await dispatch(
{
id: 'req-add',
command: 'destinations-add',
args: { agent_group_id: SOURCE, local_name: 'helper', target_type: 'agent', target_id: TARGET },
},
{ caller: 'host' },
);
// Precondition: add succeeded and projected to both sessions.
expect(readSessionDestinations(SOURCE, SESSION_A)).toHaveLength(1);
expect(readSessionDestinations(SOURCE, SESSION_B)).toHaveLength(1);
const resp = await dispatch(
{
id: 'req-remove',
command: 'destinations-remove',
args: { agent_group_id: SOURCE, local_name: 'helper' },
},
{ caller: 'host' },
);
expect(resp.ok).toBe(true);
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
});
});
+29 -1
View File
@@ -1,6 +1,32 @@
import { getDb } from '../../db/connection.js';
import { getDb, hasTable } from '../../db/connection.js';
import { getSessionsByAgentGroup } from '../../db/sessions.js';
import { log } from '../../log.js';
import { registerResource } from '../crud.js';
/**
* Project the agent's central `agent_destinations` rows into every active
* session's `inbound.db`. The agent-to-agent module is optional, so we guard
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily —
* same pattern as container-runner.ts on container wake.
*
* Called from both `add` and `remove` so the live container picks up the
* change without waiting for the next spawn. Without this, send_message to
* the new local_name silently drops with "unknown destination" until restart.
* See the destination-projection invariant in
* src/modules/agent-to-agent/db/agent-destinations.ts.
*/
async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
if (!hasTable(getDb(), 'agent_destinations')) return;
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
for (const session of getSessionsByAgentGroup(agentGroupId)) {
try {
writeDestinations(agentGroupId, session.id);
} catch (err) {
log.warn('Failed to project destinations to session inbound.db', { agentGroupId, sessionId: session.id, err });
}
}
}
registerResource({
name: 'destination',
plural: 'destinations',
@@ -56,6 +82,7 @@ registerResource({
VALUES (?, ?, ?, ?, datetime('now'))`,
)
.run(agentGroupId, localName, targetType, targetId);
await projectDestinationsToSessions(agentGroupId);
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
},
},
@@ -71,6 +98,7 @@ registerResource({
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
.run(agentGroupId, localName);
if (result.changes === 0) throw new Error('destination not found');
await projectDestinationsToSessions(agentGroupId);
return { removed: { agent_group_id: agentGroupId, local_name: localName } };
},
},
@@ -31,6 +31,8 @@
* Affected call sites today (keep this list honest if you add more):
* - src/delivery.ts::handleSystemAction case 'create_agent'
* - src/db/messaging-groups.ts::createMessagingGroupAgent
* - src/cli/resources/destinations.ts::add / remove (admin-time `ncl destinations`
* — iterates over `getSessionsByAgentGroup(agentGroupId)`)
*/
import type { AgentDestination } from '../../../types.js';
import { getDb } from '../../../db/connection.js';