mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-27 18:34:58 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d3eca7027 | |||
| 1d6bba4d3f | |||
| 9bb69c0e50 | |||
| add6145f1c | |||
| 4e14d08173 | |||
| 15292ae76c | |||
| 055cf49bd5 |
@@ -246,30 +246,40 @@ If one or more `[BREAKING]` lines are found:
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Check for skill and channel/provider updates
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
## 7a: Skill branches
|
||||
Check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
Updating your installed skills is **part of** updating NanoClaw, not an optional
|
||||
extra. Channel and provider code ships on long-lived branches (`channels`,
|
||||
`providers`) that the host merge above doesn't touch — so stopping here leaves
|
||||
that code on whatever version you installed, which is how an important upstream
|
||||
fix gets silently left behind. The default is to continue into `/update-skills`,
|
||||
which re-applies your installed channels/providers to pull their latest code.
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
|
||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
||||
Detect whether anything is installed: read `src/channels/index.ts` and
|
||||
`src/providers/index.ts`, collecting `import './<name>.js';` lines (excluding
|
||||
`cli`).
|
||||
|
||||
## 7b: Channel and provider updates
|
||||
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
|
||||
- If nothing is installed: skip silently and proceed to Step 7.9.
|
||||
- If one or more are installed: continue into skill updates.
|
||||
|
||||
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
|
||||
- List the installed channels/providers.
|
||||
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
|
||||
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
|
||||
- "Skip — I'll update them later"
|
||||
- Set `multiSelect: true`
|
||||
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
|
||||
**Hand-off — default in, minimal opt-out.** Use AskUserQuestion (single-select).
|
||||
Name the installed skills in the question so the choice is concrete:
|
||||
- Question: "Skill updates are part of this NanoClaw update — your installed
|
||||
channels/providers (<list the detected ones>) ride separate branches the host
|
||||
update didn't touch. Continue into `/update-skills` to bring them up to date?"
|
||||
- Option 1 (Recommended): "Continue into skill updates" — description: "Runs
|
||||
`/update-skills`, which re-applies your installed channels/providers to pull
|
||||
their latest upstream code. You pick which ones there."
|
||||
- Option 2: "Skip — I'll run `/update-skills` myself later" — description: "Your
|
||||
installed skill code stays as-is and may be behind upstream."
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
Keep it to these two options — the per-skill selection lives inside
|
||||
`/update-skills`, not here.
|
||||
|
||||
- On "Continue": invoke `/update-skills` using the Skill tool. (If the re-apply
|
||||
touches container code, `/update-skills` rebuilds the agent image itself — see
|
||||
its Step 4 — so nothing container-related is owed back here.)
|
||||
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ For each selected skill (process one at a time):
|
||||
After all selected skills are re-applied:
|
||||
- `pnpm run build`
|
||||
- `pnpm test` (do not fail the flow if tests are not configured)
|
||||
- If the re-apply changed any files under `container/` (`git diff --name-only -- container/` is non-empty), rebuild the agent image so new sessions pick up the new code: `./container/build.sh`. Skill code that lives in the container (e.g. a provider's runtime) keeps running the old image until this is done — the rebuild is what makes the fix live, not the file copy. If nothing under `container/` changed (e.g. only a channel adapter was re-applied), skip it.
|
||||
|
||||
Each channel/provider skill copies in its own registration test; those run as part of `pnpm test` and assert the barrel still registers the adapter against the freshly fetched code.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **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.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
|
||||
@@ -341,6 +341,12 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:la
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps → `docker run --cpus/--memory`. Empty default =
|
||||
// no flag = unbounded (today's behavior). Opt in to bound a fleet sharing one
|
||||
// host: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g. Swap is a host concern
|
||||
// (run the host swapless to make --memory a hard cap); not managed here.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
|
||||
```
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
|
||||
|
||||
// The reaper deletes config files from ~/Library/LaunchAgents (or the systemd
|
||||
// user dir). We point HOME at a throwaway temp dir so real registrations are
|
||||
// never touched, and force os.platform() so the launchd/systemd branch runs
|
||||
// regardless of the host running the suite. The best-effort unload inside the
|
||||
// reaper (launchctl/systemctl) is swallowed when the binary is absent, so these
|
||||
// tests are deterministic on both macOS and Linux CI.
|
||||
|
||||
function tempHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'peer-cleanup-'));
|
||||
}
|
||||
|
||||
function writePlist(filePath: string, target: string): void {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>/usr/bin/node</string><string>${target}</string></array>
|
||||
</dict></plist>`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeUnit(filePath: string, target: string): void {
|
||||
fs.writeFileSync(filePath, `[Service]\nExecStart=/usr/bin/node ${target}\n`);
|
||||
}
|
||||
|
||||
const created: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of created.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('cleanupUnhealthyPeers — dead launchd registrations', () => {
|
||||
function setup(): { home: string; agentsDir: string; projectRoot: string } {
|
||||
const home = tempHome();
|
||||
created.push(home);
|
||||
const agentsDir = path.join(home, 'Library', 'LaunchAgents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(home);
|
||||
vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
return { home, agentsDir, projectRoot: path.join(home, 'install') };
|
||||
}
|
||||
|
||||
it('removes a plist whose target binary is gone', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const dead = path.join(agentsDir, 'com.nanoclaw-v2-dead.plist');
|
||||
writePlist(dead, path.join(agentsDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(dead)).toBe(false);
|
||||
expect(result.removed.map((r) => r.label)).toContain('com.nanoclaw-v2-dead');
|
||||
});
|
||||
|
||||
it('leaves a plist whose target still exists', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const liveTarget = path.join(agentsDir, 'live', 'dist', 'index.js');
|
||||
fs.mkdirSync(path.dirname(liveTarget), { recursive: true });
|
||||
fs.writeFileSync(liveTarget, '// host entry');
|
||||
const live = path.join(agentsDir, 'com.nanoclaw-v2-live.plist');
|
||||
writePlist(live, liveTarget);
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(live)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never reaps this install's own plist, even with a missing target", () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const ownLabel = getLaunchdLabel(projectRoot);
|
||||
const own = path.join(agentsDir, `${ownLabel}.plist`);
|
||||
writePlist(own, path.join(agentsDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(own)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores an unrecognized plist (no dist/index.js target)', () => {
|
||||
const { agentsDir, projectRoot } = setup();
|
||||
const weird = path.join(agentsDir, 'com.nanoclaw-v2-weird.plist');
|
||||
fs.writeFileSync(weird, '<plist><dict></dict></plist>');
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(weird)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupUnhealthyPeers — dead systemd registrations', () => {
|
||||
function setup(): { unitDir: string; projectRoot: string } {
|
||||
const home = tempHome();
|
||||
created.push(home);
|
||||
const unitDir = path.join(home, '.config', 'systemd', 'user');
|
||||
fs.mkdirSync(unitDir, { recursive: true });
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(home);
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
return { unitDir, projectRoot: path.join(home, 'install') };
|
||||
}
|
||||
|
||||
it('removes a unit whose target binary is gone', () => {
|
||||
const { unitDir, projectRoot } = setup();
|
||||
const dead = path.join(unitDir, 'nanoclaw-v2-dead.service');
|
||||
writeUnit(dead, path.join(unitDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(dead)).toBe(false);
|
||||
expect(result.removed.map((r) => r.label)).toContain('nanoclaw-v2-dead');
|
||||
});
|
||||
|
||||
it("never reaps this install's own unit", () => {
|
||||
const { unitDir, projectRoot } = setup();
|
||||
const ownUnit = getSystemdUnit(projectRoot);
|
||||
const own = path.join(unitDir, `${ownUnit}.service`);
|
||||
writeUnit(own, path.join(unitDir, 'gone', 'dist', 'index.js'));
|
||||
|
||||
const result = cleanupUnhealthyPeers(projectRoot);
|
||||
|
||||
expect(fs.existsSync(own)).toBe(true);
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+112
-3
@@ -11,6 +11,14 @@
|
||||
* - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD`
|
||||
* - systemd: unit is in `failed` state, OR `activating` with many restarts
|
||||
*
|
||||
* Separately, a peer registration is "dead" when the program it launches no
|
||||
* longer exists on disk — almost always a deleted test checkout or worktree.
|
||||
* The service manager keeps retrying the missing binary forever, and the
|
||||
* health probes can't see it because an unloaded/inactive job doesn't report
|
||||
* via `launchctl print` / `systemctl show`. Deleting an install's folder
|
||||
* without running the uninstaller leaves these behind, so they accumulate. We
|
||||
* unload and delete the orphaned config file outright.
|
||||
*
|
||||
* Healthy peers are left alone — multiple installs can coexist fine now that
|
||||
* container-reaper is label-scoped.
|
||||
*/
|
||||
@@ -35,6 +43,7 @@ export interface PeerStatus {
|
||||
export interface PeerCleanupResult {
|
||||
checked: PeerStatus[];
|
||||
unloaded: PeerStatus[];
|
||||
removed: Array<{ label: string; configPath: string }>;
|
||||
failures: Array<{ label: string; err: string }>;
|
||||
}
|
||||
|
||||
@@ -50,7 +59,39 @@ export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): Peer
|
||||
if (platform === 'linux') {
|
||||
return cleanupSystemdPeers(projectRoot);
|
||||
}
|
||||
return { checked: [], unloaded: [], failures: [] };
|
||||
return { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a dead peer's job (best-effort) and delete its orphaned config file.
|
||||
* `unload` runs first and may throw harmlessly when the job isn't loaded or the
|
||||
* service-manager binary is absent (e.g. exercising launchd cleanup on Linux).
|
||||
*/
|
||||
function reapDeadPeer(
|
||||
result: PeerCleanupResult,
|
||||
peer: { label: string; configPath: string },
|
||||
unload: () => void,
|
||||
kind: string,
|
||||
missingTarget: string,
|
||||
): void {
|
||||
try {
|
||||
unload();
|
||||
} catch {
|
||||
/* job not loaded — nothing to unload */
|
||||
}
|
||||
try {
|
||||
fs.rmSync(peer.configPath, { force: true });
|
||||
log.info(`Removed dead peer ${kind}`, {
|
||||
label: peer.label,
|
||||
configPath: peer.configPath,
|
||||
missingTarget,
|
||||
});
|
||||
result.removed.push(peer);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`Failed to remove dead peer ${kind}`, { label: peer.label, err: message });
|
||||
result.failures.push({ label: peer.label, err: message });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- launchd (macOS) --------------------------------------------------------
|
||||
@@ -58,7 +99,7 @@ export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): Peer
|
||||
function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownLabel = getLaunchdLabel(projectRoot);
|
||||
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
|
||||
let plists: string[];
|
||||
try {
|
||||
@@ -76,6 +117,20 @@ function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const label = path.basename(plistPath, '.plist');
|
||||
if (label === ownLabel) continue;
|
||||
|
||||
const missingTarget = deadLaunchdTarget(plistPath);
|
||||
if (missingTarget) {
|
||||
reapDeadPeer(
|
||||
result,
|
||||
{ label, configPath: plistPath },
|
||||
// Best-effort unload in case launchd still has it registered; throwing
|
||||
// (not loaded, or launchctl absent off-macOS) is expected and ignored.
|
||||
() => execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' }),
|
||||
'launchd plist',
|
||||
missingTarget,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = probeLaunchdPeer(label, plistPath, uid);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
@@ -121,12 +176,32 @@ function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerSt
|
||||
return { label, configPath: plistPath, state, runs, unhealthy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the program path a launchd plist launches when that program no longer
|
||||
* exists on disk (a dead registration), or undefined when the plist is
|
||||
* unreadable, has an unrecognized shape, or its target still exists — in which
|
||||
* case the plist must not be touched.
|
||||
*/
|
||||
function deadLaunchdTarget(plistPath: string): string | undefined {
|
||||
let xml: string;
|
||||
try {
|
||||
xml = fs.readFileSync(plistPath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// ProgramArguments is [nodePath, "<projectRoot>/dist/index.js"]; the host
|
||||
// entry point is the stable marker to match on.
|
||||
const target = /<string>([^<]*\/dist\/index\.js)<\/string>/.exec(xml)?.[1];
|
||||
if (!target) return undefined;
|
||||
return fs.existsSync(target) ? undefined : target;
|
||||
}
|
||||
|
||||
// ---- systemd (Linux) --------------------------------------------------------
|
||||
|
||||
function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
|
||||
const ownUnit = getSystemdUnit(projectRoot);
|
||||
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
|
||||
const result: PeerCleanupResult = { checked: [], unloaded: [], removed: [], failures: [] };
|
||||
|
||||
let units: string[];
|
||||
try {
|
||||
@@ -141,6 +216,22 @@ function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
|
||||
for (const unit of units) {
|
||||
if (unit === ownUnit) continue;
|
||||
|
||||
const unitPath = path.join(unitDir, `${unit}.service`);
|
||||
const missingTarget = deadSystemdTarget(unitPath);
|
||||
if (missingTarget) {
|
||||
reapDeadPeer(
|
||||
result,
|
||||
{ label: unit, configPath: unitPath },
|
||||
() => {
|
||||
execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' });
|
||||
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'pipe' });
|
||||
},
|
||||
'systemd unit',
|
||||
missingTarget,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = probeSystemdPeer(unit);
|
||||
if (!status) continue;
|
||||
result.checked.push(status);
|
||||
@@ -184,3 +275,21 @@ function probeSystemdPeer(unit: string): PeerStatus | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the program path a systemd unit launches when that program no longer
|
||||
* exists on disk (a dead registration), or undefined when the unit is
|
||||
* unreadable, has an unrecognized shape, or its target still exists.
|
||||
*/
|
||||
function deadSystemdTarget(unitPath: string): string | undefined {
|
||||
let unit: string;
|
||||
try {
|
||||
unit = fs.readFileSync(unitPath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
// ExecStart=<nodePath> <projectRoot>/dist/index.js
|
||||
const target = /^ExecStart=\S+\s+(\S+\/dist\/index\.js)\s*$/m.exec(unit)?.[1];
|
||||
if (!target) return undefined;
|
||||
return fs.existsSync(target) ? undefined : target;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,12 @@ export async function run(_args: string[]): Promise<void> {
|
||||
labels: peerReport.unloaded.map((p) => p.label),
|
||||
});
|
||||
}
|
||||
if (peerReport.removed.length > 0) {
|
||||
log.warn('Removed dead peer NanoClaw registrations (target binary missing)', {
|
||||
count: peerReport.removed.length,
|
||||
labels: peerReport.removed.map((p) => p.label),
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'macos') {
|
||||
setupLaunchd(projectRoot, nodePath, homeDir);
|
||||
|
||||
@@ -38,6 +38,11 @@ export const ONECLI_API_KEY = process.env.ONECLI_API_KEY || envConfig.ONECLI_API
|
||||
export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10);
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps, passed through to `docker run`. Default empty =
|
||||
// no flag added = today's unbounded behavior (don't OOM existing OSS workloads).
|
||||
// Operators opt in: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
@@ -47,6 +47,37 @@ describe('buildContainerArgs ordering invariant (structural)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-container resource limits (structural)', () => {
|
||||
// CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT pass through to `docker run` as
|
||||
// --cpus / --memory, but only when set. The default is empty string → no flag →
|
||||
// today's unbounded behavior (don't OOM existing OSS workloads). Swap is not
|
||||
// managed here (a swapless host makes --memory a hard cap). buildContainerArgs
|
||||
// needs a live gateway to drive, so guard the wiring structurally: the flags
|
||||
// must be pushed, and each must be guarded by its env knob so empty emits nothing.
|
||||
it('reads both limit knobs from config', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toContain('CONTAINER_CPU_LIMIT');
|
||||
expect(src).toContain('CONTAINER_MEMORY_LIMIT');
|
||||
});
|
||||
|
||||
it('guards --cpus behind a truthy CONTAINER_CPU_LIMIT', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toMatch(/if \(CONTAINER_CPU_LIMIT\)[\s\S]*?args\.push\('--cpus', CONTAINER_CPU_LIMIT\)/);
|
||||
});
|
||||
|
||||
it('guards --memory behind a truthy CONTAINER_MEMORY_LIMIT (and sets no swap flag)', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toMatch(/if \(CONTAINER_MEMORY_LIMIT\) args\.push\('--memory', CONTAINER_MEMORY_LIMIT\)/);
|
||||
expect(src).not.toContain('--memory-swap');
|
||||
});
|
||||
|
||||
it('defaults both knobs to empty string in config (no flag = unbounded)', () => {
|
||||
const cfg = fs.readFileSync(path.join(process.cwd(), 'src', 'config.ts'), 'utf-8');
|
||||
expect(cfg).toContain("CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || ''");
|
||||
expect(cfg).toContain("CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || ''");
|
||||
});
|
||||
});
|
||||
|
||||
describe('container boot-failure tripwire (structural)', () => {
|
||||
// A container that dies at boot (unknown provider, missing CLI binary, bad
|
||||
// config) explains itself only on stderr — which logs at debug, below the
|
||||
|
||||
@@ -10,9 +10,11 @@ import path from 'path';
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
import {
|
||||
CONTAINER_CPU_LIMIT,
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_IMAGE_BASE,
|
||||
CONTAINER_INSTALL_LABEL,
|
||||
CONTAINER_MEMORY_LIMIT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
ONECLI_API_KEY,
|
||||
@@ -434,6 +436,13 @@ async function buildContainerArgs(
|
||||
): Promise<string[]> {
|
||||
const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL];
|
||||
|
||||
// Per-container resource caps (opt-in; empty = unbounded, today's behavior).
|
||||
// Only --memory is set. Whether that's a hard cap depends on the host having no
|
||||
// swap (a deployment concern) — on a swapless host --memory is hard and a runaway
|
||||
// is OOM-killed; we don't manage swap from here.
|
||||
if (CONTAINER_CPU_LIMIT) args.push('--cpus', CONTAINER_CPU_LIMIT);
|
||||
if (CONTAINER_MEMORY_LIMIT) args.push('--memory', CONTAINER_MEMORY_LIMIT);
|
||||
|
||||
// Environment — only vars read by code we don't own.
|
||||
// Everything NanoClaw-specific is in container.json (read by runner at startup).
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
Reference in New Issue
Block a user