mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c84dec8e9 | |||
| 092487d7ad | |||
| 87850aa7f8 | |||
| 526170fd47 | |||
| 2d9375531b | |||
| e734e5cddd |
@@ -28,6 +28,15 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
|
||||
|
||||
---
|
||||
|
||||
# Phase 0: Refresh this skill first
|
||||
|
||||
The migration process itself evolves, so run its newest version before doing anything else:
|
||||
- Ensure the `upstream` remote exists (default `https://github.com/nanocoai/nanoclaw.git`) and fetch: `git fetch upstream --prune`. Detect the upstream branch (`main` or `master`).
|
||||
- Refresh this skill from upstream: `git checkout upstream/<branch> -- .claude/skills/migrate-nanoclaw/`
|
||||
- Re-read `.claude/skills/migrate-nanoclaw/SKILL.md`. If it changed, **follow the updated version from the top** instead of this one.
|
||||
|
||||
This is the only working-tree change expected before the preflight check below; changes limited to `.claude/skills/migrate-nanoclaw/` are this self-refresh — ignore them in the 1.0 clean-tree check and proceed.
|
||||
|
||||
# Phase 1: Extract
|
||||
|
||||
## 1.0 Preflight
|
||||
@@ -464,6 +473,11 @@ Point the branch at the upgraded state with `git reset --hard <upgrade-commit>`
|
||||
|
||||
Run `pnpm install && pnpm run build` in the main tree to confirm.
|
||||
|
||||
Stamp the upgrade marker (required — without it the startup tripwire stops the host on next start). Only do this after the build above succeeds:
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts set "" migrate-nanoclaw
|
||||
```
|
||||
|
||||
Restart the service. Service labels are per-install — derive them from `setup/lib/install-slug.sh`:
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
@@ -60,11 +60,20 @@ Help a user with a customized NanoClaw install safely incorporate upstream chang
|
||||
- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option.
|
||||
- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files.
|
||||
|
||||
# Step 0a: Refresh this skill first
|
||||
The update process itself evolves, so run its newest version before doing anything else:
|
||||
- Ensure the `upstream` remote exists (default `https://github.com/nanocoai/nanoclaw.git`) and fetch: `git fetch upstream --prune`. Detect the upstream branch (`main` or `master`).
|
||||
- Refresh this skill from upstream: `git checkout upstream/<branch> -- .claude/skills/update-nanoclaw/`
|
||||
- Re-read `.claude/skills/update-nanoclaw/SKILL.md`. If it changed, **follow the updated version from the top** instead of this one.
|
||||
|
||||
This is the only working-tree change expected before the preflight check; the full update commits it along with everything else.
|
||||
|
||||
# Step 0: Preflight (stop early if unsafe)
|
||||
Run:
|
||||
- `git status --porcelain`
|
||||
If output is non-empty:
|
||||
- Tell the user to commit or stash first, then stop.
|
||||
- Exception: changes limited to `.claude/skills/update-nanoclaw/` are the Step 0a self-refresh — ignore those and proceed.
|
||||
|
||||
Confirm remotes:
|
||||
- `git remote -v`
|
||||
@@ -256,6 +265,16 @@ If any channels/providers are installed AND `upstream/channels` or `upstream/pro
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
# Step 7.9: Stamp the upgrade marker (required)
|
||||
After validation has **succeeded**, record that this install reached the new version through the supported path. Without this, the startup tripwire stops the host on its next start.
|
||||
|
||||
- `pnpm exec tsx scripts/upgrade-state.ts set "" update-nanoclaw`
|
||||
- The empty version argument stamps the current `package.json` version.
|
||||
|
||||
If validation did NOT succeed, do not stamp — leave the tripwire to catch the broken state.
|
||||
|
||||
Proceed to Step 8.
|
||||
|
||||
# Step 8: Summary + rollback instructions
|
||||
|
||||
@@ -18,12 +18,20 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Bump patch version
|
||||
run: |
|
||||
# Skip the auto-bump when the pushed commits already changed the
|
||||
# version themselves (e.g. a release PR that set a minor/major).
|
||||
# Otherwise the bot would patch a deliberate 2.1.0 up to 2.1.1.
|
||||
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qx 'package.json'; then
|
||||
echo "package.json already changed in this push; skipping auto-bump."
|
||||
exit 0
|
||||
fi
|
||||
pnpm version patch --no-git-tag-version
|
||||
git add package.json
|
||||
git diff --cached --quiet && exit 0
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [2.1.0] - 2026-06-07
|
||||
|
||||
- [BREAKING] **Startup now requires an upgrade marker.** The host refuses to boot unless `data/upgrade-state.json` records that this install reached the current version through a sanctioned path (`/setup`, `/update-nanoclaw`, `/migrate-nanoclaw`). After this update completes — and before restarting the service — stamp the marker by running `pnpm exec tsx scripts/upgrade-state.ts set`. If the host has already tripped on restart with "update did not go through the supported path", that same command clears it. See [docs/upgrade-recovery.md](docs/upgrade-recovery.md).
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Recovering from the upgrade tripwire
|
||||
|
||||
If NanoClaw refuses to start with a message like *"update did not go through the supported path"*, this page explains what happened and how to clear it.
|
||||
|
||||
## What happened
|
||||
|
||||
NanoClaw records the version it reached each time you upgrade through a supported path — `/setup`, `/update-nanoclaw`, or `/migrate-nanoclaw`. That record lives in `data/upgrade-state.json`.
|
||||
|
||||
At startup the host checks that record against the running code. If it's **missing** or its version **doesn't match** the code, the host stops. This almost always means the code was updated by a raw `git pull` instead of the supported flow — so migrations, dependency installs, or container rebuilds that the flow would have run may not have happened.
|
||||
|
||||
## If you just ran a supported upgrade
|
||||
|
||||
If you reached this point by running `/update-nanoclaw`, `/migrate-nanoclaw`, or `/setup` and it **completed successfully**, this is expected the first time an existing install meets the tripwire (your previous version predated it). Clear it by stamping the current version:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts set
|
||||
```
|
||||
|
||||
Then restart NanoClaw. It won't trip again.
|
||||
|
||||
**Coding agents:** if the upgrade you just ran reported success, run the command above automatically and restart. Only stop to ask a human if the upgrade itself failed or you can't confirm it finished.
|
||||
|
||||
## If you ran `git pull` yourself
|
||||
|
||||
Don't just clear the tripwire — that skips the work the supported flow does. Instead, run the supported upgrade so backups, migrations, and rebuilds happen:
|
||||
|
||||
```
|
||||
/update-nanoclaw
|
||||
```
|
||||
|
||||
Once it finishes it stamps the marker for you, and the next start is clean.
|
||||
|
||||
## If you have your own upgrade flow
|
||||
|
||||
If you've built your own way to upgrade — a custom skill, a deploy script, a CI job, a service that pulls and restarts — it won't stamp the marker, so the host will trip on the next start. Add the stamp as the **last step** of that flow, after the upgrade succeeds and before the restart:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts set
|
||||
```
|
||||
|
||||
That's the same thing `/setup`, `/update-nanoclaw`, and `/migrate-nanoclaw` do at the end. Do it only when the upgrade actually completed — the marker is your assertion that this install reached the current version through a path you trust.
|
||||
|
||||
## The override
|
||||
|
||||
`pnpm exec tsx scripts/upgrade-state.ts set` is the override: it declares "this install is good at the current version." Use it when you know the install is actually in a good state (e.g. you completed the steps manually). It's safe to re-run.
|
||||
|
||||
To inspect the current marker:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts get
|
||||
```
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.76",
|
||||
"version": "2.1.0",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* scripts/upgrade-state.ts — read or stamp the upgrade marker.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/upgrade-state.ts get
|
||||
* pnpm exec tsx scripts/upgrade-state.ts set [version] [via]
|
||||
*
|
||||
* `set` with no version stamps the current package.json version. The
|
||||
* sanctioned upgrade paths (setup / update / migrate) call `set` on
|
||||
* success; running it by hand is also the documented way to clear the
|
||||
* startup tripwire — see docs/upgrade-recovery.md.
|
||||
*/
|
||||
import { getCodeVersion, markerPath, readUpgradeState, writeUpgradeState } from '../src/upgrade-state.js';
|
||||
|
||||
const [, , cmd, versionArg, viaArg] = process.argv;
|
||||
|
||||
if (cmd === 'get') {
|
||||
const state = readUpgradeState();
|
||||
console.log(state ? JSON.stringify(state) : 'none');
|
||||
} else if (cmd === 'set') {
|
||||
const state = writeUpgradeState({ version: versionArg || getCodeVersion(), via: viaArg || 'manual' });
|
||||
console.log(`Stamped ${markerPath()}: ${JSON.stringify(state)}`);
|
||||
} else {
|
||||
console.error('Usage: pnpm exec tsx scripts/upgrade-state.ts get | set [version] [via]');
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { writeUpgradeState } from '../src/upgrade-state.js';
|
||||
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
|
||||
import {
|
||||
commandExists,
|
||||
@@ -54,6 +55,11 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
|
||||
|
||||
// Stamp the upgrade marker before the host first starts, so the startup
|
||||
// tripwire (enforceUpgradeTripwire) sees this as a sanctioned install.
|
||||
const stamped = writeUpgradeState({ via: 'setup' });
|
||||
log.info('Stamped upgrade marker', { version: stamped.version });
|
||||
|
||||
// Peer preflight — a crash-looping peer install (most often the legacy v1
|
||||
// `com.nanoclaw` plist) will keep trashing this install's containers on
|
||||
// every respawn via its own cleanupOrphans. Detect and unload any peer
|
||||
|
||||
@@ -17,6 +17,7 @@ import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, st
|
||||
import { startHostSweep, stopHostSweep } from './host-sweep.js';
|
||||
import { routeInbound } from './router.js';
|
||||
import { log } from './log.js';
|
||||
import { enforceUpgradeTripwire } from './upgrade-state.js';
|
||||
|
||||
// Response + shutdown registries live in response-registry.ts to break the
|
||||
// circular import cycle: src/index.ts imports src/modules/index.js for side
|
||||
@@ -69,6 +70,10 @@ async function main(): Promise<void> {
|
||||
// 0. Circuit breaker — backoff on rapid restarts
|
||||
await enforceStartupBackoff();
|
||||
|
||||
// 0.5 Upgrade tripwire — refuse to start if this install was updated
|
||||
// outside the sanctioned path (raw `git pull` instead of /update-nanoclaw).
|
||||
enforceUpgradeTripwire();
|
||||
|
||||
// 1. Init central DB
|
||||
const dbPath = path.join(DATA_DIR, 'v2.db');
|
||||
const db = initDb(dbPath);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-upgrade-state' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-upgrade-state';
|
||||
|
||||
import {
|
||||
enforceUpgradeTripwire,
|
||||
getCodeVersion,
|
||||
isUpgradeCurrent,
|
||||
markerPath,
|
||||
readUpgradeState,
|
||||
writeUpgradeState,
|
||||
} from './upgrade-state.js';
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('upgrade-state', () => {
|
||||
it('getCodeVersion reads the package.json version', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
|
||||
expect(getCodeVersion()).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it('readUpgradeState returns null when the marker is absent', () => {
|
||||
expect(readUpgradeState()).toBeNull();
|
||||
});
|
||||
|
||||
it('write then read round-trips, with version/via/updatedAt', () => {
|
||||
const written = writeUpgradeState({ version: '9.9.9', via: 'test' });
|
||||
expect(written).toMatchObject({ version: '9.9.9', via: 'test' });
|
||||
expect(written.updatedAt).toBeTruthy();
|
||||
expect(readUpgradeState()).toEqual(written);
|
||||
});
|
||||
|
||||
it('write defaults the version to the code version', () => {
|
||||
expect(writeUpgradeState({ via: 'test' }).version).toBe(getCodeVersion());
|
||||
});
|
||||
|
||||
it('isUpgradeCurrent: false when absent, false on mismatch, true on match', () => {
|
||||
expect(isUpgradeCurrent()).toBe(false);
|
||||
writeUpgradeState({ version: '0.0.0-nope', via: 'test' });
|
||||
expect(isUpgradeCurrent()).toBe(false);
|
||||
writeUpgradeState({ version: getCodeVersion(), via: 'test' });
|
||||
expect(isUpgradeCurrent()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a corrupt marker as absent (fails closed, never throws)', () => {
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(TEST_DIR, 'upgrade-state.json'), '{ this is not json');
|
||||
expect(() => readUpgradeState()).not.toThrow();
|
||||
expect(readUpgradeState()).toBeNull();
|
||||
expect(isUpgradeCurrent()).toBe(false);
|
||||
});
|
||||
|
||||
it('markerPath is upgrade-state.json under the data dir', () => {
|
||||
expect(markerPath()).toBe(path.join(TEST_DIR, 'upgrade-state.json'));
|
||||
});
|
||||
|
||||
it('enforceUpgradeTripwire exits when not current and passes when current', () => {
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}) as never);
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// No marker → trips.
|
||||
expect(() => enforceUpgradeTripwire()).toThrow('exit:1');
|
||||
|
||||
// Stale marker → trips.
|
||||
writeUpgradeState({ version: '0.0.0-nope', via: 'test' });
|
||||
expect(() => enforceUpgradeTripwire()).toThrow('exit:1');
|
||||
|
||||
// Matching marker → passes.
|
||||
writeUpgradeState({ version: getCodeVersion(), via: 'test' });
|
||||
expect(() => enforceUpgradeTripwire()).not.toThrow();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Upgrade marker — the record that an install reached its current version
|
||||
* through a sanctioned path (setup / `/update-nanoclaw` / `/migrate-nanoclaw`).
|
||||
*
|
||||
* The startup tripwire (enforceUpgradeTripwire) refuses to run if the marker
|
||||
* is missing or its version doesn't match the running code — i.e. if the
|
||||
* install was updated by a raw `git pull` instead of the supported flow.
|
||||
*
|
||||
* The marker lives in `data/` (gitignored), so a `git pull` can't touch it.
|
||||
* Only the sanctioned paths call writeUpgradeState(); clearing the tripwire
|
||||
* by hand is the same `set` — see docs/upgrade-recovery.md.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
export interface UpgradeState {
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
via: string;
|
||||
}
|
||||
|
||||
const MARKER_PATH = path.join(DATA_DIR, 'upgrade-state.json');
|
||||
const FIX_COMMAND = 'pnpm exec tsx scripts/upgrade-state.ts set';
|
||||
|
||||
/** Version the running code declares, read from package.json. */
|
||||
export function getCodeVersion(): string {
|
||||
const pkgPath = path.join(process.cwd(), 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
|
||||
if (!pkg.version) throw new Error(`No version field in ${pkgPath}`);
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the upgrade marker, or null if it's absent, unreadable, or corrupt.
|
||||
* Never throws — a boot gate must fail closed (treat anything it can't trust
|
||||
* as "no valid marker" → trip), not crash with a stack trace.
|
||||
*/
|
||||
export function readUpgradeState(): UpgradeState | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(MARKER_PATH, 'utf8');
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
log.warn('Could not read upgrade marker; treating as absent', { path: MARKER_PATH, err: String(e) });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as UpgradeState;
|
||||
} catch {
|
||||
log.warn('Upgrade marker is corrupt; treating as absent', { path: MARKER_PATH });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the marker. Only the sanctioned paths (setup / update / migrate)
|
||||
* call this on success; `version` defaults to the current code version.
|
||||
*/
|
||||
export function writeUpgradeState(opts: { version?: string; via: string }): UpgradeState {
|
||||
const state: UpgradeState = {
|
||||
version: opts.version ?? getCodeVersion(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
via: opts.via,
|
||||
};
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(MARKER_PATH, JSON.stringify(state, null, 2) + '\n');
|
||||
return state;
|
||||
}
|
||||
|
||||
/** True when the marker exists and matches the running code version. */
|
||||
export function isUpgradeCurrent(): boolean {
|
||||
const state = readUpgradeState();
|
||||
return state !== null && state.version === getCodeVersion();
|
||||
}
|
||||
|
||||
/** Absolute path to the marker file. */
|
||||
export function markerPath(): string {
|
||||
return MARKER_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup gate. If the install didn't reach the current version through a
|
||||
* sanctioned path, stop with a message written for the coding agent that
|
||||
* just ran the upgrade to act on automatically.
|
||||
*/
|
||||
export function enforceUpgradeTripwire(): void {
|
||||
if (isUpgradeCurrent()) return;
|
||||
|
||||
const code = getCodeVersion();
|
||||
const recorded = readUpgradeState()?.version ?? 'none';
|
||||
|
||||
console.error(
|
||||
[
|
||||
'',
|
||||
'='.repeat(64),
|
||||
'NanoClaw stopped: update did not go through the supported path',
|
||||
'='.repeat(64),
|
||||
'This install was updated outside the sanctioned upgrade flow',
|
||||
'(no upgrade marker, or the marker version does not match the code).',
|
||||
'',
|
||||
` code version: ${code}`,
|
||||
` recorded version: ${recorded}`,
|
||||
'',
|
||||
"IF YOU'RE A HUMAN: you most likely ran `git pull` directly. Don't",
|
||||
'fix this by hand — open Claude Code in this folder and run',
|
||||
'/update-nanoclaw, which repairs the install and clears this stop.',
|
||||
'',
|
||||
'CODING AGENT: if you just ran /update-nanoclaw, /migrate-nanoclaw, or',
|
||||
'setup and it completed successfully, clear this automatically by running:',
|
||||
'',
|
||||
` ${FIX_COMMAND}`,
|
||||
'',
|
||||
'then restart NanoClaw. If the upgrade did NOT complete, stop and',
|
||||
'investigate instead of clearing.',
|
||||
'',
|
||||
'Details: docs/upgrade-recovery.md',
|
||||
'='.repeat(64),
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
log.error('Upgrade tripwire: install not on the sanctioned path', { code, recorded });
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user