Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ apps/*/log_*.json
# Local runtime config files
config/

# Local live supervisor artifacts
logs/live-supervisor/
# Local logs
logs/

# Local scratch files
.scratch/
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,15 @@ Rebuild disposable live configs from `ICKB_TESTNET_BOT_PRIVATE_KEY` and `ICKB_TE

`pnpm live:preflight -- --config config/bot-testnet.json --role bot` prints public balance evidence for funding checks. Use `key.recommendedAddress` as the funding address, then rerun preflight and check `balances.CKB.available`, `balances.CKB.reserve`, `balances.CKB.spendable`, `balances.CKB.projectedAvailable`, `balances.CKB.total`, and `capital.minimumCkbCapital`; `available` and `spendable` are actual plain-cell values, while `projectedAvailable` and `total` are projected accounting values. For machine-readable JSON without package-manager output, run `node scripts/ickb-live-preflight.mjs --config config/bot-testnet.json --role bot` directly.

For repeated bounded invocations, keep loop-owned options before `--` and supervisor options after it:
For repeated bounded invocations, keep loop-owned options before `--` and supervisor options after it. The loop owns child run directories through `--out-root`, so do not pass supervisor `--out-dir` after `--`:

```bash
pnpm live:supervisor:loop --max-runs 1 -- --scenario standard-cycle --max-cycles 1
```

Use loop-owned `--child-timeout-seconds` to bound the outer supervisor child process when running long watches; keep it long enough for the whole supervisor invocation, including actor preflights and actor commands, so the supervisor remains alive to enforce its own `--command-timeout-seconds` process-group cleanup.
By default the loop prebuilds the local CCC fork plus bot, tester, and supervisor runtime before the first run. Use loop-owned `--skip-build` only when another wrapper has already built those artifacts. Use loop-owned `--child-timeout-seconds` to bound the outer supervisor child process when running long watches; keep it long enough for the whole supervisor invocation, including actor preflights and actor commands, so the supervisor remains alive to enforce its own `--command-timeout-seconds` process-group cleanup.

For continuous live matching, use the dynamic external loop. It reads only tester preflight balance summaries, chooses a fundable tester stimulus (`all-ckb-limit-order` when plain CKB can preserve reserve plus overhead, otherwise `ickb-to-ckb-limit-order` with the smaller live fee when iCKB is available), then runs bounded `scripts/ickb-supervisor-loop.mjs` chunks:
For continuous live matching, use the dynamic external loop. It reads only tester preflight balance summaries, chooses `all-ckb-limit-order` when `CKB.available >= 3001`, otherwise chooses `ickb-to-ckb-limit-order` with `--tester-fee 1 --tester-fee-base 1000` when `CKB.available >= 2100` and `ICKB.available >= 100`, otherwise leaves the tester scenario as `auto`, then runs bounded `scripts/ickb-supervisor-loop.mjs` chunks:

```bash
pnpm live:supervisor:dynamic-loop
Expand Down
6 changes: 3 additions & 3 deletions apps/supervisor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ The KISS watcher script runs one deterministic supervisor invocation per child o
node scripts/ickb-supervisor-loop.mjs --max-runs 1 --stable-limit 2 --backoff-seconds 0 -- --scenario standard-cycle --max-cycles 1
```

Loop-owned options go before `--`; supervisor options go after `--`. If using `pnpm live:supervisor:loop`, keep loop-owned options before the first `--` so they are not passed through to the supervisor. The loop stops on supervisor nonzero exit, incident artifacts listed in `summary.json`, tx-creating outcomes or tx hashes for tx-creating outcomes, a new outcome after the first run, repeated no-progress signatures, or `--max-runs`. `-- --help` and `-- -h` are child help passthroughs: the delegated help is printed and the wrapper exits with the child status.
Loop-owned options go before `--`; supervisor options go after `--`. If using `pnpm live:supervisor:loop`, keep loop-owned options before the first `--` so they are not passed through to the supervisor. The loop owns child run directories through `--out-root`, so do not pass supervisor `--out-dir` after `--`. The loop stops on supervisor nonzero exit, incident artifacts listed in `summary.json`, tx-creating outcomes or tx hashes for tx-creating outcomes, a new outcome after the first run, repeated no-progress signatures, or `--max-runs`. `-- --help` and `-- -h` are child help passthroughs: the delegated help is printed and the wrapper exits with the child status.

The external loop also has a loop-owned `--child-timeout-seconds` guard for the supervisor child process. Keep it long enough for the whole delegated supervisor run, including actor preflights and actor commands, not just one `--command-timeout-seconds` window. The dynamic loop defaults this guard to the supervisor-loop default so the supervisor keeps ownership of killing funded actor process groups on command timeout.
By default the loop prebuilds the local CCC fork plus bot, tester, and supervisor runtime before the first run. Use loop-owned `--skip-build` only when another wrapper has already built those artifacts. The external loop also has a loop-owned `--child-timeout-seconds` guard for the supervisor child process. Keep it long enough for the whole delegated supervisor run, including actor preflights and actor commands, not just one `--command-timeout-seconds` window. The dynamic loop defaults this guard to the supervisor-loop default so the supervisor keeps ownership of killing funded actor process groups on command timeout.

For continuous tester-bot matching, use `node scripts/ickb-supervisor-dynamic-loop.mjs` or `pnpm live:supervisor:dynamic-loop`. This remains outside `apps/supervisor`: it reads tester preflight balance summaries, chooses a currently fundable tester scenario, and delegates each bounded chunk to `scripts/ickb-supervisor-loop.mjs`. When `--target-outcome tester_fresh_order_skip` is passed through, supervisor auto-planning can choose `tester-fresh-skip-two-pass`; the dynamic loop itself only chooses fundable tester stimuli. The dynamic loop also treats `-- --help` and `-- -h` as child help passthroughs and exits with the delegated status.
For continuous tester-bot matching, use `node scripts/ickb-supervisor-dynamic-loop.mjs` or `pnpm live:supervisor:dynamic-loop`. This remains outside `apps/supervisor`: it reads tester preflight balance summaries, chooses `all-ckb-limit-order` when `CKB.available >= 3001`, otherwise chooses `ickb-to-ckb-limit-order` with `--tester-fee 1 --tester-fee-base 1000` when `CKB.available >= 2100` and `ICKB.available >= 100`, otherwise leaves tester selection as `auto`, and delegates each bounded chunk to `scripts/ickb-supervisor-loop.mjs`. When `--target-outcome tester_fresh_order_skip` is passed through, supervisor auto-planning can choose `tester-fresh-skip-two-pass`; the dynamic loop itself only chooses fundable tester stimuli. The dynamic loop also treats `-- --help` and `-- -h` as child help passthroughs and exits with the delegated status.

Loop and dynamic-loop exit codes are operator-visible control flow: tx/new-outcome stops exit `0`, incidents exit `2`, `max_runs` and `stable_no_progress` inspection stops exit `3`, and child nonzero statuses are preserved.

Expand Down
64 changes: 62 additions & 2 deletions apps/supervisor/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,60 @@ describe("supervisor CLI", () => {
const plan = resolvePlan(args, "/repo", { spawnSyncCommand: ignoredChecker(true) });

await expect(supervise(args, plan, {
stat: () => Promise.resolve({} as never),
mkdir: () => Promise.resolve(undefined),
mkdir: ((path: string) => {
if (pathToString(path) === "/repo/logs/live-supervisor/existing") {
const error = new Error("exists") as NodeJS.ErrnoException;
error.code = "EEXIST";
throw error;
}
return Promise.resolve(undefined);
}) as never,
})).rejects.toThrow("Output directory already exists: logs/live-supervisor/existing");
});

it("creates only parent directories recursively before reserving a fresh output directory", async () => {
const args = parseArgs(["--dry-run", "--out-dir", "logs/live-supervisor/fresh"]);
const plan = resolvePlan(args, "/repo", { spawnSyncCommand: ignoredChecker(true) });
const mkdirs: Array<{ path: string; recursive?: boolean }> = [];

await supervise(args, plan, {
lstat: missingStat,
realpath: (path) => Promise.resolve(pathToString(path)),
mkdir: ((path: string, options?: { recursive?: boolean }) => {
mkdirs.push({ path: pathToString(path), recursive: options?.recursive });
return Promise.resolve(undefined);
}) as never,
writeFile: () => Promise.resolve(),
appendFile: () => Promise.resolve(),
});

expect(mkdirs).toContainEqual({ path: "/repo/logs/live-supervisor", recursive: true });
expect(mkdirs).toContainEqual({ path: "/repo/logs/live-supervisor/fresh", recursive: undefined });
});

it("refuses output directories created after ancestor checks", async () => {
const args = parseArgs(["--dry-run", "--out-dir", "logs/live-supervisor/raced"]);
const plan = resolvePlan(args, "/repo", { spawnSyncCommand: ignoredChecker(true) });

await expect(supervise(args, plan, {
lstat: missingStat,
mkdir: ((path: string) => {
if (pathToString(path) === "/repo/logs/live-supervisor/raced") {
const error = new Error("exists") as NodeJS.ErrnoException;
error.code = "EEXIST";
throw error;
}
return Promise.resolve(undefined);
}) as never,
writeFile: () => {
throw new Error("should not write artifacts after raced output directory");
},
appendFile: () => {
throw new Error("should not write events after raced output directory");
},
})).rejects.toThrow("Output directory already exists: logs/live-supervisor/raced");
});

it("refuses symlinked supervisor artifact parents", async () => {
const args = parseArgs(["--dry-run", "--out-dir", "logs/live-supervisor/symlink-parent"]);
const plan = resolvePlan(args, "/repo", { spawnSyncCommand: ignoredChecker(true) });
Expand Down Expand Up @@ -1272,6 +1321,17 @@ describe("classification", () => {
expect(classifyActorResult("tester", commandResult("tester", JSON.stringify({
skip: { reason: "sampled-amount-too-small" },
}))).outcome).toBe("tester_sampled_too_small_skip");
expect(classifyActorResult("tester", commandResult("tester", JSON.stringify({
skip: {
reason: "estimated-conversion-too-small",
requestedTesterScenario: "auto",
attemptedTesterScenarios: ["random-order", "sdk-conversion", "bounded-ickb-to-ckb-limit-order"],
},
})))).toMatchObject({
outcome: "tester_estimated_too_small_skip",
terminal: false,
skipReason: "estimated-conversion-too-small",
});
expect(classifyActorResult("tester", commandResult("tester", JSON.stringify({
skip: { reason: "post-tx-ckb-reserve" },
})))).toMatchObject({
Expand Down
18 changes: 11 additions & 7 deletions apps/supervisor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn, spawnSync, type SpawnSyncReturns } from "node:child_process";
import { existsSync } from "node:fs";
import { appendFile, lstat, mkdir, realpath, stat, writeFile } from "node:fs/promises";
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";

Expand Down Expand Up @@ -947,18 +947,18 @@ async function runDryRun(plan: SupervisorPlan, ledger: CoverageLedger, dependenc
}

async function prepareOutputDirectory(plan: SupervisorPlan, dependencies: Dependencies): Promise<void> {
const statFn = dependencies.stat ?? stat;
const mkdirFn = dependencies.mkdir ?? mkdir;
await assertNoSymlinkedOutputAncestors(plan, dependencies);
await mkdirFn(dirname(plan.outDir), { recursive: true });
await assertNoSymlinkedOutputAncestors(plan, dependencies);
try {
await statFn(plan.outDir);
throw new Error(`Output directory already exists: ${plan.relativeOutDir}`);
await mkdirFn(plan.outDir);
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
if (isAlreadyExistsError(error)) {
throw new Error(`Output directory already exists: ${plan.relativeOutDir}`);
}
throw error;
}
await mkdirFn(plan.outDir, { recursive: true });
await assertRealOutputDirectory(plan, dependencies);
}

Expand Down Expand Up @@ -2460,6 +2460,10 @@ function isNotFoundError(error: unknown): boolean {
return isRecord(error) && error["code"] === "ENOENT";
}

function isAlreadyExistsError(error: unknown): boolean {
return isRecord(error) && error["code"] === "EEXIST";
}

function jsonReplacer(_key: string, value: unknown): unknown {
return typeof value === "bigint" ? value.toString() : value;
}
Expand Down
79 changes: 61 additions & 18 deletions apps/tester/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
testerAttemptedTransactionEvidence,
testerEstimatedTooSmallSkip,
testerExecutionActions,
testerNoActionableAutoScenarioSkip,
resolveTesterScenario,
testerReserveSkip,
TesterTerminalError,
Expand Down Expand Up @@ -351,26 +352,64 @@ describe("planTesterTransaction", () => {
});
const random = vi.spyOn(Math, "random").mockReturnValue(0.999999);
try {
expect(planTesterTransaction(state, 1000n, "random-order")).toEqual({
const plan = planTesterTransaction(state, 1000n, "random-order");
expect(plan).toMatchObject({
direction: "ickb-to-ckb",
amount: ccc.fixedPointFrom(123),
ckbAmount: 0n,
udtAmount: ccc.fixedPointFrom(123),
orderCount: 1,
});
expect(plan.amount).toBeGreaterThan(1n << 33n);
expect(plan.amount).toBeLessThanOrEqual(ccc.fixedPointFrom(123));
expect(plan.udtAmount).toBe(plan.amount);
} finally {
random.mockRestore();
}
});

it("does not auto-select unbuildable tiny SDK conversions before other funded auto choices", () => {
it("does not auto-select random orders when only dust is available", () => {
expect(resolveTesterScenario(
testerState({ availableCkbBalance: ccc.fixedPointFrom(2000) + 1n, availableIckbBalance: 0n }),
"auto",
undefined,
1000n,
() => 0.99,
)).toBe("random-order");
)).toBeUndefined();
});

it("treats auto with capital but no actionable scenario as a nonterminal estimate skip", () => {
const liveNearReserveState = testerState({
availableCkbBalance: 229423868188n,
availableIckbBalance: 147394003472899n,
exchangeRatio: { ckbScale: 10000000000000000n, udtScale: 11845567055823930n },
feeRate: 33222n,
});

expect(resolveTesterScenario(
liveNearReserveState,
"auto",
undefined,
11845567055823n,
() => 0,
)).toBeUndefined();
expect(testerNoActionableAutoScenarioSkip()).toEqual({
reason: "estimated-conversion-too-small",
requestedTesterScenario: "auto",
attemptedTesterScenarios: ["random-order", "sdk-conversion", "bounded-ickb-to-ckb-limit-order"],
});
});

it("samples random order amounts above the matcher minimum", () => {
const state = testerState({ availableCkbBalance: ccc.fixedPointFrom(3000), availableIckbBalance: 0n });
const random = vi.spyOn(Math, "random").mockReturnValue(0);
try {
const plan = planTesterTransaction(state, ccc.fixedPointFrom(1000), "random-order");
expect(plan.direction).toBe("ckb-to-ickb");
expect(plan.amount).toBeGreaterThan(1n << 33n);
expect(plan.amount).toBeLessThanOrEqual(ccc.fixedPointFrom(1000));
expect(plan.ckbAmount).toBe(plan.amount);
} finally {
random.mockRestore();
}
});

it("plans SDK conversions only in a buildable funded direction", () => {
Expand Down Expand Up @@ -420,13 +459,13 @@ describe("planTesterTransaction", () => {

it("resolves auto only to scenarios funded by current balances", () => {
const ckbOnlyState = testerState({ availableCkbBalance: ccc.fixedPointFrom(3000), availableIckbBalance: 0n });
expect(resolveTesterScenario(ckbOnlyState, "auto", undefined, 1000n, () => 0)).toBe("random-order");
expect(resolveTesterScenario(ckbOnlyState, "auto", undefined, 1000n, () => 0.99)).toBe("sdk-conversion");
expect(resolveTesterScenario(ckbOnlyState, "auto", undefined, ccc.fixedPointFrom(1000), () => 0)).toBe("random-order");
expect(resolveTesterScenario(ckbOnlyState, "auto", undefined, ccc.fixedPointFrom(1000), () => 0.99)).toBe("sdk-conversion");

const ickbOnlyState = testerState({ availableCkbBalance: 0n, availableIckbBalance: ccc.fixedPointFrom(10) });
expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, 1000n, () => 0)).toBe("random-order");
expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, 1000n, () => 0.5)).toBe("sdk-conversion");
expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, 1000n, () => 0.99)).toBe("bounded-ickb-to-ckb-limit-order");
const ickbOnlyState = testerState({ availableCkbBalance: 0n, availableIckbBalance: ccc.fixedPointFrom(123) });
expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, ccc.fixedPointFrom(1000), () => 0)).toBe("random-order");
expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, ccc.fixedPointFrom(1000), () => 0.5)).toBe("sdk-conversion");
expect(resolveTesterScenario(ickbOnlyState, "auto", undefined, ccc.fixedPointFrom(1000), () => 0.99)).toBe("bounded-ickb-to-ckb-limit-order");

const mixedMultiOrderState = testerState({
availableCkbBalance: ccc.fixedPointFrom(650000),
Expand All @@ -436,7 +475,7 @@ describe("planTesterTransaction", () => {
mixedMultiOrderState,
"auto",
undefined,
1000n,
ccc.fixedPointFrom(1000),
() => sample,
));
expect(autoSamples).not.toContain("all-ckb-limit-order");
Expand All @@ -447,13 +486,13 @@ describe("planTesterTransaction", () => {
expect(autoSamples).not.toContain("dust-ckb-conversion");
expect(autoSamples).not.toContain("dust-ickb-conversion");

expect(() => resolveTesterScenario(
expect(resolveTesterScenario(
testerState({ availableCkbBalance: ccc.fixedPointFrom(2000), availableIckbBalance: 0n }),
"auto",
undefined,
1000n,
() => 0,
)).toThrow("Not enough funds for auto tester scenario");
)).toBeUndefined();
});

it("computes post-transaction plain CKB reserve from unspent inputs and account outputs", () => {
Expand Down Expand Up @@ -875,14 +914,18 @@ function estimatedOrder(
function testerState(values: {
availableCkbBalance: bigint;
availableIckbBalance?: bigint;
exchangeRatio?: TesterState["system"]["exchangeRatio"];
feeRate?: bigint;
capacityCells?: ccc.Cell[];
userOrders?: never[];
}): TesterState {
const availableIckbBalance = values.availableIckbBalance ?? 0n;
const exchangeRatio = values.exchangeRatio ?? { ckbScale: 1n, udtScale: 1n };
const feeRate = values.feeRate ?? 1000n;
return {
system: {
exchangeRatio: { ckbScale: 1n, udtScale: 1n },
feeRate: 1000n,
exchangeRatio,
feeRate,
tip: headerLike({ timestamp: 0n }),
orderPool: [],
ckbAvailable: values.availableCkbBalance,
Expand All @@ -899,8 +942,8 @@ function testerState(values: {
userOrders: values.userOrders ?? [],
conversionContext: {
system: {
exchangeRatio: { ckbScale: 1n, udtScale: 1n },
feeRate: 1000n,
exchangeRatio,
feeRate,
tip: headerLike({ timestamp: 0n }),
orderPool: [],
ckbAvailable: values.availableCkbBalance,
Expand Down
Loading
Loading