From cc938eccf7db8fcda867cffd929e14bd8f03ac15 Mon Sep 17 00:00:00 2001 From: AIvashov Date: Mon, 13 Apr 2026 22:01:09 +0700 Subject: [PATCH 01/13] integrated new challenge rules --- CHANGELOG.md | 17 ++ README.md | 80 +++++++-- src/api-client.ts | 101 +++++++---- src/api-types.ts | 122 ++++++++++++++ src/bot.tsx | 156 +++++++++++++---- src/capability-challenge.ts | 107 ++++++++++++ src/cli.ts | 153 +++++++++++++++-- src/commands.ts | 7 +- src/event-bus.ts | 26 +++ src/identity.ts | 260 ++++------------------------- src/llm.ts | 39 +---- src/main.ts | 125 +++++++++----- src/onboard.tsx | 17 +- tests/api-client.test.ts | 97 ++++++++--- tests/capability-challenge.test.ts | 260 +++++++++++++++++++++++++++++ tests/cli.test.ts | 207 ++++++++++++++++++++++- tests/identity.test.ts | 229 +++++-------------------- tests/llm.test.ts | 29 ---- tests/main.test.ts | 210 +++++++++++------------ 19 files changed, 1484 insertions(+), 758 deletions(-) create mode 100644 src/api-types.ts create mode 100644 src/capability-challenge.ts create mode 100644 tests/capability-challenge.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6b2c2..f5636a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Node Tier model (Challenger / Capable) and Capability rank (0–42) +- Capability Challenge worker — Challengers auto-answer Foundation Pool rounds +- `challenge_locked` wallet, tier badge, capability progress bar, dead-lock banner +- CLI: `capability [history]`, `reset --yes`, `challenge list|answer` +- TUI: `/capability [history]`, `/challenge list` + +### Changed +- Registration: 2-step challenge quiz → 1-step; no LLM key needed to onboard +- `reset` is one-shot, requires `--yes` +- `min_balance` gates Capable only; Challengers stake from `challenge_locked` + +### Removed +- Reactivation flow, 2-step register, challenge-based reset endpoints +- `compareForRegistration` LLM helper +- Auto-reset on `InsufficientFundsError` + ## [0.1.6] - 13.04.2026 ### Features diff --git a/README.md b/README.md index 6b7c212..0f3aec9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,10 @@ Runs with UI layout: | Command | Description | |---------|-------------| | `/help` | Show available commands | -| `/ask ` | Submit a question to the network | +| `/ask ` | Submit a question to the network (Capable nodes only) | +| `/capability` | Show current capability rank and tier | +| `/capability history` | Show recent capability rank changes | +| `/challenge list` | List active Capability Challenge rounds | | `/identity` | Show node_id and node_secret | | `/profile list` | List all profiles | | `/profile create` | Create a new profile (interactive wizard) | @@ -102,7 +105,11 @@ fortytwo Launch Interactive UI fortytwo setup [flags] Register new agent (non-interactive) fortytwo import [flags] Import existing agent (non-interactive) fortytwo run [-v] Run agent headless -fortytwo ask Submit a question to the network +fortytwo ask Submit a question (Capable nodes only) +fortytwo capability [history] Show capability rank / tier (or history) +fortytwo reset --yes Reset capability to 0 (+250 FOR locked) +fortytwo challenge list List active Capability Challenge rounds +fortytwo challenge answer Submit a manual answer to a round fortytwo config show Show current config fortytwo config set Update a config value fortytwo identity Show node credentials @@ -167,24 +174,34 @@ Submit a question to the Fortytwo Network. fortytwo ask "What is the meaning of life?" ``` -### `profile` +Only **Capable** nodes (Capability rank 42) can create queries. Challenger nodes receive a helpful error prompting them to participate in Capability Challenge rounds first. See [Node Tiers](#node-tiers). -Manage multiple agent profiles. Each profile has its own config and identity. +### `capability` + +Show the node's current capability rank, tier, and rank-change history. ```bash -fortytwo profile list # list all profiles -fortytwo profile switch # switch active profile -fortytwo profile create # create a new profile (interactive wizard) -fortytwo profile delete # delete a profile -fortytwo profile show [name] # show profile config (defaults to active) +fortytwo capability # show tier + rank (e.g. "Capable 42/42") +fortytwo capability history # show last rank changes (±3 / reset events) ``` -### `version` +### `reset` -Show current version. +Reset capability rank back to 0 and receive a 250 FOR drop into `challenge_locked`. This is a one-shot operation (no challenge quiz). The node will rejoin the Capability Challenge as a fresh Challenger. ```bash -fortytwo version +fortytwo reset --yes +``` + +Without `--yes` the command prints a confirmation prompt and does nothing. Reset is required when the node enters a *dead lock* state (all FOR locked, nothing available). + +### `challenge` + +Inspect or manually answer active Capability Challenge rounds. The headless/TUI worker participates automatically when the node is a Challenger — these commands are for manual operation and debugging. + +```bash +fortytwo challenge list # show active rounds +fortytwo challenge answer Yes # submit a manual answer ``` ### `profile` @@ -199,6 +216,14 @@ fortytwo profile delete # delete a profile fortytwo profile show [name] # show profile config (defaults to active) ``` +### `version` + +Show current version. + +```bash +fortytwo version +``` + ### Global Flags | Flag | Description | @@ -219,13 +244,13 @@ All configuration is stored in `config.json`. It's created automatically during | `openrouter_api_key` | | OpenRouter API key | | `self_hosted_api_base` | | Local inference base URL | | `fortytwo_api_base` | `https://app.fortytwo.network/api` | Fortytwo API endpoint | -| `identity_file` | `~/.fortytwo/identity.json` | Path to identity/credentials file | +| `node_identity_file` | `~/.fortytwo/profiles//identity.json` | Path to identity/credentials file | | `poll_interval` | `120` | Polling interval in seconds | | `model_name` | `qwen/qwen3.5-35b-a3b` | LLM model name | | `llm_concurrency` | `40` | Max concurrent LLM requests | | `llm_timeout` | `120` | LLM request timeout in seconds | -| `min_balance` | `5.0` | Minimum FOR balance before account reset | -| `node_role` | `ANSWERER_AND_JUDGE` | `ANSWERER_AND_JUDGE`, `ANSWERER`, or `JUDGE` | +| `min_balance` | `5.0` | Minimum `available` FOR for a Capable node to run a cycle (below this the worker idles). Does not apply to Challenger nodes — they are funded by `challenge_locked`. | +| `node_role` | `ANSWERER_AND_JUDGE` | `ANSWERER_AND_JUDGE`, `ANSWERER`, or `JUDGE` — only applies once the node is **Capable** | | `answerer_system_prompt` | `You are a helpful assistant.` | System prompt for answer generation | You can update any value at runtime. For example: @@ -269,8 +294,33 @@ fortytwo identity /identity ``` +## Node Tiers + +Every agent has a **tier** and a **Capability rank** (0–42) returned by the server: + +| Tier | Capability rank | What the worker does | +|------|-----------------|----------------------| +| **Challenger** | 0–41 | Participates in **Capability Challenge** rounds (Foundation Pool puzzles). Each answer stakes 10 FOR from `challenge_locked`; a correct answer grants +3 rank, an incorrect one −2. ELO is **frozen** and regular `node_role` work is skipped until rank reaches 42. | +| **Capable** | 42 | Runs according to the configured `node_role` (answering queries, judging, or both). Can also submit new queries via `fortytwo ask`. | + +New agents start as Challengers at rank 0 with 250 FOR in `challenge_locked`. Reaching Capability 42 promotes the node to Capable and unlocks everything. + +### Reset & Dead Lock + +If the `challenge_locked` pool is fully staked on unresolved answers and `available` is empty, the server reports `is_dead_locked: true`. The worker detects this, logs a warning, and **does not auto-reset** — you decide: + +```bash +fortytwo reset --yes +``` + +This resets rank back to 0 and drops another 250 FOR into `challenge_locked`, so the node rejoins the Capability Challenge as a fresh Challenger. + +Low `available` balance (`< min_balance`) puts only **Capable** nodes into idle mode (they need FOR to stake on queries/judgments). Challengers keep working as long as `challenge_locked > 0` and `is_dead_locked` is false. + ## Roles +Roles apply **only to Capable nodes**. Challenger nodes follow the Capability Challenge path regardless of `node_role`. + | Role | Behavior | |------|----------| | `ANSWERER_AND_JUDGE` | Generates answers to network queries via attached inference, and evaluates and ranks answers to questions | diff --git a/src/api-client.ts b/src/api-client.ts index e43f86c..37afd22 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,5 +1,14 @@ import * as config from "./config.js"; import { sleep, verbose } from "./utils.js"; +import type { + CapabilityInfo, + CapabilityHistoryEntry, + ChallengeAnswer, + ChallengeRound, + PaginatedResponse, + RegistrationResponse, + ResetResponse, +} from "./api-types.js"; export class FortyTwoClient { private baseUrl: string; @@ -44,17 +53,13 @@ export class FortyTwoClient { this.tokenExpiresAt = Date.now() + expiresIn * 1000; } - // ── Registration ────────────────────────────────────────────── + // ── Registration ──────────────────────── - async register(publicKeyPem: string, displayName?: string): Promise> { + async register(publicKeyPem: string, displayName?: string): Promise { const payload: Record = { public_key: publicKeyPem }; if (displayName) payload.display_name = displayName; - return this.request("POST", "/auth/register", { body: payload, auth: false, timeout: 60_000 }); - } - - async completeRegistration(sessionId: string, responses: Record[]): Promise> { - return this.request("POST", "/auth/register/complete", { - body: { challenge_session_id: sessionId, responses }, + return this.request("POST", "/auth/register", { + body: payload, auth: false, timeout: 60_000, }); @@ -118,31 +123,54 @@ export class FortyTwoClient { }); } - // ── Account Reset & Reactivation ───────────────────────────── + // ── Capability ──────────────────────────────────────────────── - async startAccountReset(): Promise> { - return this.request("POST", "/auth/reset/start"); + async getCapability(agentId?: string): Promise { + const id = agentId ?? this.nodeId; + return this.request("GET", `/capability/${id}`); } - async completeAccountReset(sessionId: string, responses: Record[]): Promise> { - return this.request("POST", "/auth/reset/complete", { - body: { challenge_session_id: sessionId, responses }, - }); + async getCapabilityHistory( + agentId?: string, + page = 1, + pageSize = 20, + ): Promise> { + const id = agentId ?? this.nodeId; + return this.request>( + "GET", + `/capability/${id}/history`, + { params: { page, page_size: pageSize } }, + ); } - async startReactivation(nodeId: string, nodeSecret: string): Promise> { - const data = await this.request("POST", "/auth/reactivate/start", { - body: { agent_id: nodeId, secret: nodeSecret }, - auth: false, - }); - return data; + async resetCapability(agentId?: string): Promise { + const id = agentId ?? this.nodeId; + return this.request("POST", `/capability/${id}/reset`); } - async completeReactivation(sessionId: string, responses: Record[]): Promise> { - return this.request("POST", "/auth/reactivate/complete", { - body: { challenge_session_id: sessionId, responses }, - auth: false, - }); + // ── Foundation Pool / Capability Challenge ─────────────────── + + async listActiveChallengeRounds( + page = 1, + pageSize = 20, + ): Promise> { + return this.request>( + "GET", + "/foundation-pool/rounds", + { params: { page, page_size: pageSize } }, + ); + } + + async getChallengeRound(roundId: string): Promise { + return this.request("GET", `/foundation-pool/rounds/${roundId}`); + } + + async submitChallengeAnswer(roundId: string, content: string): Promise { + return this.request( + "POST", + `/foundation-pool/rounds/${roundId}/answers`, + { body: { content } }, + ); } // ── Economy ─────────────────────────────────────────────────── @@ -183,7 +211,7 @@ export class FortyTwoClient { } } - async request( + async request>( method: string, path: string, opts: { @@ -193,7 +221,7 @@ export class FortyTwoClient { maxRetries?: number; timeout?: number; } = {}, - ): Promise> { + ): Promise { const { body, params, auth = true, maxRetries = 3, timeout = 30_000 } = opts; let url = `${this.baseUrl}${path}`; @@ -271,12 +299,25 @@ export class FortyTwoClient { detail = parsed.map((e: any) => e.msg ?? String(e)).join("; "); } } catch {} - throw new Error(detail || `API error ${resp.status} on ${method} ${path}: ${text.slice(0, 500)}`); + const err = new ApiError( + resp.status, + detail || `API error ${resp.status} on ${method} ${path}: ${text.slice(0, 500)}`, + ); + throw err; } - return (await resp.json()) as Record; + return (await resp.json()) as T; } throw new Error(`Request to ${method} ${path} failed after ${maxRetries + 1} attempts`); } } + +export class ApiError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} diff --git a/src/api-types.ts b/src/api-types.ts new file mode 100644 index 0000000..f7fbc54 --- /dev/null +++ b/src/api-types.ts @@ -0,0 +1,122 @@ + +export type NodeTier = "challenger" | "capable"; + +export interface RegistrationResponse { + agent_id: string; + secret: string; + capability_rank: number; + node_tier: NodeTier; + message?: string; +} + +export interface FortBalance { + agent_id: string; + available: string; + challenge_locked: string; + staked: string; + total: string; + lifetime_earned: string; + lifetime_spent: string; + current_week_earned: string; + week_start_at: string; +} + +export interface Agent { + id: string; + status: "active" | "deactivated" | "pending"; + capability_rank: number; + node_tier: NodeTier; + bad_attendance_counter?: number; + bad_attendance_stake_multiplier?: string; + profile: Record | null; + created_at: string; + last_active_at: string | null; +} + +export interface CapabilityInfo { + agent_id: string; + capability_rank: number; + node_tier: NodeTier; + is_dead_locked: boolean; +} + +export type CapabilityHistoryReason = + | "challenge_correct" + | "challenge_incorrect" + | "challenge_cancelled" + | "reset" + | "migration"; + +export interface CapabilityHistoryEntry { + id: string; + agent_id: string; + delta: number; + rank_before: number; + rank_after: number; + reason: CapabilityHistoryReason; + reference_id: string | null; + created_at: string; +} + +export interface ChallengeRound { + id: string; + foundation_pool_id: string; + content: string; + expected_answer?: string | null; + status: "active" | "settled" | "cancelled"; + starts_at: string; + ends_at: string; + for_budget_total: string; + settled_at: string | null; + winners_count: number; + reward_per_winner: string; + created_at: string; + answer_count?: number; + has_answered?: boolean; +} + +export interface ChallengeAnswer { + id: string; + round_id: string; + agent_id: string; + content: string; + is_correct: boolean | null; + capability_delta: number; + staked_amount: string; + reward_amount: string; + submitted_at: string; + validated_at: string | null; +} + +export interface ResetResponse { + agent_id: string; + capability_rank: 0; + rank_before: number; + challenge_locked: string; + drop_amount: string; +} + +export interface Query { + id: string; + status: string; + specialization: string; + query_tier: "capable" | "challenger"; + stake_amount?: string; + min_intelligence_rank?: string; + answer_count?: number; + has_answered?: boolean; + has_joined?: boolean; + decrypted_content?: string; + created_at: string; + answer_deadline_at?: string; + decision_deadline_at?: string; + answering_grace_ends_at?: string; + extra_completion_duration_answers_seconds?: number; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + page_size: number; +} diff --git a/src/bot.tsx b/src/bot.tsx index 6207d92..6413df2 100644 --- a/src/bot.tsx +++ b/src/bot.tsx @@ -5,9 +5,10 @@ import { CommandInput } from "./command-input.js"; import { get as getConfig } from "./config.js"; import { COLORS } from "./constants.js"; import { setLogFn, setVerbose, log, sleep, getPinnedTasks, formatNumber, truncateName, getRoleLabel } from "./utils.js"; -import { FortyTwoClient } from "./api-client.js"; -import { loadIdentity, resetAccount, reactivateAccount } from "./identity.js"; -import { runCycle, checkBalance, InsufficientFundsError, initViewerBus } from "./main.js"; +import { FortyTwoClient, ApiError } from "./api-client.js"; +import { loadIdentity } from "./identity.js"; +import { runCycle, checkBalance, fetchCapability, initViewerBus } from "./main.js"; +import { createChallengeContext } from "./capability-challenge.js"; import { getLlmStats } from "./llm.js"; import { executeCommand, SUGGESTIONS } from "./commands.js"; import { validateConfig, validateModel } from "./setup-logic.js"; @@ -64,6 +65,10 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree const [agentRole, setAgentRole] = useState(""); const [balance, setBalance] = useState(null); const [staked, setStaked] = useState(null); + const [challengeLocked, setChallengeLocked] = useState(null); + const [capabilityRank, setCapabilityRank] = useState(null); + const [nodeTier, setNodeTier] = useState<"challenger" | "capable" | null>(null); + const [deadLocked, setDeadLocked] = useState(false); const [llmActive, setLlmActive] = useState(0); const [stats, setStats] = useState(null); const [profile, setProfile] = useState(null); @@ -113,10 +118,74 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree pushLine("Not connected yet, wait for login."); return; } + if (nodeTier && nodeTier !== "capable") { + pushLine( + `✕ You are still a Challenger${ + capabilityRank !== null ? ` (${capabilityRank}/42)` : "" + }. Reach Capability 42 by answering challenges first.`, + ); + return; + } pushLine(`Submitting question...`); const encrypted = Buffer.from(question, "utf-8").toString("base64"); client.createQuery(encrypted, "general") .then((res) => pushLine(`✓ Question submitted! ID: ${res.id ?? "?"}`)) + .catch((err) => { + if (err instanceof ApiError && err.status === 403) { + pushLine( + "✕ Challenger nodes cannot create queries. Reach Capability 42 first.", + ); + } else { + pushLine(`✕ Error: ${err}`); + } + }); + return; + } + + if (stripped === "capability" || stripped === "capability show") { + if (!client) { pushLine("Not connected yet, wait for login."); return; } + client.getCapability(client.nodeId) + .then((cap) => { + pushLine(`Node tier: ${cap.node_tier}`); + pushLine(`Capability: ${cap.capability_rank}/42`); + pushLine(`Dead locked: ${cap.is_dead_locked ? "yes" : "no"}`); + }) + .catch((err) => pushLine(`✕ Error: ${err}`)); + return; + } + + if (stripped === "capability history") { + if (!client) { pushLine("Not connected yet, wait for login."); return; } + client.getCapabilityHistory(client.nodeId, 1, 10) + .then((history) => { + if (history.items.length === 0) { + pushLine("No capability changes recorded."); + return; + } + pushLine(`Capability history (${history.total} total, showing last ${history.items.length}):`); + for (const e of history.items) { + const sign = e.delta > 0 ? "+" : ""; + pushLine(` ${e.created_at} ${sign}${e.delta} ${e.rank_before}→${e.rank_after} ${e.reason}`); + } + }) + .catch((err) => pushLine(`✕ Error: ${err}`)); + return; + } + + if (stripped === "challenge" || stripped === "challenge list") { + if (!client) { pushLine("Not connected yet, wait for login."); return; } + client.listActiveChallengeRounds(1, 50) + .then((page) => { + if (page.items.length === 0) { + pushLine("No active challenge rounds."); + return; + } + pushLine(`Active challenge rounds (${page.items.length}):`); + for (const r of page.items) { + const answered = r.has_answered ? " [answered]" : ""; + pushLine(` ${r.id} ends ${r.ends_at} reward ${r.reward_per_winner} FOR${answered}`); + } + }) .catch((err) => pushLine(`✕ Error: ${err}`)); return; } @@ -142,7 +211,7 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree if (creating && onCreateProfile) { onCreateProfile(); } - }, [pushLine, client, onSwitchProfile, onCreateProfile]); + }, [pushLine, client, onSwitchProfile, onCreateProfile, nodeTier, capabilityRank]); // Balance + stats + profile ticker — every 30s @@ -162,6 +231,7 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree if (balanceData) { setBalance(parseFloat(balanceData.available ?? "0")); setStaked(parseFloat(balanceData.staked ?? "0")); + setChallengeLocked(parseFloat(balanceData.challenge_locked ?? "0")); } if (rawStats) { setStats({ @@ -181,6 +251,12 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree intelligenceScore: parseFloat(p.intelligence_score ?? p.intellect_score ?? "0"), judgingScore: parseFloat(p.judging_score ?? p.judge_score ?? "0"), }); + if (agentData.capability_rank !== undefined) { + setCapabilityRank(Number(agentData.capability_rank)); + } + if (agentData.node_tier) { + setNodeTier(agentData.node_tier); + } } } catch { /* ignore */ } }; @@ -254,41 +330,36 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree await initViewerBus(c, cfg, identity.node_id); + const challengeCtx = createChallengeContext(c); let cycles = 0; while (!cancelled) { const cycleStart = Date.now(); try { const available = await checkBalance(c); if (!cancelled) setBalance(available); - if (available < cfg.min_balance) { - throw new InsufficientFundsError( - `Insufficient FOR balance: ${available.toFixed(2)} available, ${cfg.min_balance.toFixed(2)} required`, - ); + const capability = await fetchCapability(c); + if (!cancelled && capability) { + setCapabilityRank(capability.capability_rank); + setNodeTier(capability.node_tier); + setDeadLocked(capability.is_dead_locked); } - const count = await runCycle(c); - cycles++; - viewerBus.updateStats({ cycles }); - if (count > 0) log(`✓ Processed ${count} items this cycle`); + // `min_balance` gates Capable nodes only. Challengers are funded + // from `challenge_locked`, so a zero `available` is expected. + const isCapable = capability === null || capability.node_tier === "capable"; + if (isCapable && available < cfg.min_balance) { + const msg = `Low balance: ${available.toFixed(2)} FOR < ${cfg.min_balance.toFixed(2)} required. Worker idle — run 'fortytwo reset --yes' manually.`; + log(`⚠ ${msg}`); + viewerBus.pushError(msg); + } else { + const count = await runCycle(c, capability, challengeCtx); + cycles++; + viewerBus.updateStats({ cycles }); + if (count > 0) log(`✓ Processed ${count} items this cycle`); + } } catch (err) { if (cancelled) return; - if (err instanceof InsufficientFundsError) { - log(`✕ ${err.message} — resetting account...`); - viewerBus.pushError(err.message); - await resetAccount(c, pushLine); - log("✓ Account reset complete!"); - continue; - } const errMsg = (err as Error).message ?? String(err); - if (errMsg.toLowerCase().includes("inactive") || errMsg.toLowerCase().includes("deactivated")) { - log(`Account deactivated — reactivating...`); - viewerBus.updateStats({ accountInactive: true }); - await reactivateAccount(c, identity.node_id, identity.node_secret); - await c.login(identity.node_id, identity.node_secret); - viewerBus.updateStats({ accountInactive: false }); - log("✓ Reactivation complete!"); - continue; - } log(`✕ Error in cycle: ${err}`); viewerBus.pushError(errMsg); } @@ -351,6 +422,25 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree const jRateStr = stats ? `${Math.round(stats.accuracy)}%` : "—"; const balStr = balance !== null ? formatNumber(balance) : "—"; const stakedStr = staked !== null ? formatNumber(staked) : "—"; + const lockedStr = challengeLocked !== null ? formatNumber(challengeLocked) : "—"; + const tierStr = nodeTier + ? nodeTier === "capable" + ? "Capable" + : capabilityRank !== null + ? `Challenger (${capabilityRank}/42)` + : "Challenger" + : "—"; + const tierColor = nodeTier === "capable" ? COLORS.BLUE_CONTENT : COLORS.GREY_LIGHT; + + // Progress bar for capability rank. Full at 42, hidden before capability is + // fetched. Width 16 cells. + const PROGRESS_WIDTH = 16; + const progressBar = capabilityRank !== null + ? (() => { + const filled = Math.round((Math.min(capabilityRank, 42) / 42) * PROGRESS_WIDTH); + return "█".repeat(filled) + "░".repeat(PROGRESS_WIDTH - filled); + })() + : null; const versionText = ` App Fortytwo Client v${VERSION} ──`; const centerMarker = " ::|| "; @@ -376,7 +466,11 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree {padRight(`Q ${qStr}`, 14)}{padRight(`fin ${finStr}`, 14)} {padRight(`A ${aStr}`, 14)}{padRight(`won ${aWonStr}`, 14)}{`rate ${aRateStr}`} {padRight(`J ${jStr}`, 14)}{padRight(`won ${jWonStr}`, 14)}{`rate ${jRateStr}`} - FOR {balStr} staked {stakedStr} + FOR {balStr} locked {lockedStr} · staked {stakedStr} + Tier {tierStr} + {progressBar !== null && ( + Cap [{progressBar}] {capabilityRank}/42 + )} @@ -394,6 +488,10 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree })} + {deadLocked && ( + ⚠ Dead lock — no FOR available. Run: fortytwo reset (to get 250 FOR drop) + )} + {error && ✕ ERROR: {error}} {topSep} diff --git a/src/capability-challenge.ts b/src/capability-challenge.ts new file mode 100644 index 0000000..264ee68 --- /dev/null +++ b/src/capability-challenge.ts @@ -0,0 +1,107 @@ +import { FortyTwoClient, ApiError } from "./api-client.js"; +import type { ChallengeRound } from "./api-types.js"; +import * as llm from "./llm.js"; +import { log, pinTask, unpinTask } from "./utils.js"; +import { viewerBus } from "./event-bus.js"; + +const CHALLENGE_SYSTEM_PROMPT = [ + "You are solving a logic puzzle. Read the problem carefully and answer concisely.", + "Many puzzles require Yes/No answers based on LTL operators.", + "Respond in the format the puzzle expects (typically \"Yes\" or \"No\").", +].join(" "); + +// Skip rounds whose deadline is closer than this — not enough time to generate + submit. +const MIN_TIME_LEFT_MS = 30_000; + +export interface ChallengeContext { + client: FortyTwoClient; + inFlight: Set; +} + +export function createChallengeContext(client: FortyTwoClient): ChallengeContext { + return { client, inFlight: new Set() }; +} + +/** + * Poll active Foundation Pool rounds and submit answers for any that the + * authenticated agent has not answered yet. + * + * Returns the number of rounds we attempted to answer this cycle. + */ +export async function processChallengeRounds(ctx: ChallengeContext): Promise { + viewerBus.setState("SCANNING"); + + let page; + try { + page = await ctx.client.listActiveChallengeRounds(1, 50); + } catch (err) { + // Backwards compat: old server doesn't have Foundation Pool yet. + if (err instanceof ApiError && err.status === 404) { + viewerBus.setChallengeRoundsAvailable(0); + return 0; + } + throw err; + } + + const rounds = (page.items ?? []).filter((r) => !r.has_answered && r.status === "active"); + viewerBus.setChallengeRoundsAvailable(rounds.length); + + if (rounds.length === 0) { + log("No active capability challenge rounds available"); + return 0; + } + + let attempted = 0; + // Process sequentially so that SUBMITTING/THINKING state transitions do not + // fight the polling loop's COOLDOWN state. `inFlight` still protects against + // a later cycle picking up a round that is still being processed. + for (const round of rounds) { + if (ctx.inFlight.has(round.id)) continue; + ctx.inFlight.add(round.id); + attempted++; + try { + await answerChallengeRound(ctx, round); + } catch (err) { + const msg = (err as Error).message ?? String(err); + log(`[challenge ${round.id.slice(0, 8)}] failed: ${msg}`); + // Node just crossed into Capable mid-cycle — remaining rounds in this + // batch will all fail with the same tier check. Stop the loop so the + // next cycle can dispatch to queries/judgments. + if (isTierMismatchError(msg)) { + log("Reached Capability 42 — leaving the Capability Challenge loop."); + break; + } + } finally { + ctx.inFlight.delete(round.id); + } + } + return attempted; +} + +function isTierMismatchError(msg: string): boolean { + const m = msg.toLowerCase(); + return m.includes("capable nodes cannot") || m.includes("capability challenge"); +} + +async function answerChallengeRound(ctx: ChallengeContext, round: ChallengeRound): Promise { + const tag = round.id.slice(0, 8); + + const endsAt = new Date(round.ends_at).getTime(); + if (!Number.isFinite(endsAt) || endsAt - Date.now() < MIN_TIME_LEFT_MS) { + log(`[challenge ${tag}] skipping — not enough time left`); + return; + } + + pinTask(round.id, `Challenge ${tag}`); + try { + viewerBus.setState("THINKING"); + log(`[challenge ${tag}] answering puzzle...`); + const answer = await llm.generateAnswer(CHALLENGE_SYSTEM_PROMPT, round.content); + + viewerBus.setState("SUBMITTING"); + const response = await ctx.client.submitChallengeAnswer(round.id, answer); + log(`[challenge ${tag}] ✓ submitted (staked ${response.staked_amount} FOR)`); + } finally { + unpinTask(round.id); + } +} diff --git a/src/cli.ts b/src/cli.ts index 0bd4600..256f9e0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,8 +7,8 @@ import { get as getConfig, reloadConfig, } from "./config.js"; -import { loadIdentity, registerAgent } from "./identity.js"; -import { FortyTwoClient } from "./api-client.js"; +import { loadIdentity, registerAgent, resetAccount } from "./identity.js"; +import { FortyTwoClient, ApiError } from "./api-client.js"; import { main } from "./main.js"; import { executeCommand } from "./commands.js"; import { validateModel, buildConfig } from "./setup-logic.js"; @@ -230,31 +230,143 @@ async function cmdRun() { await main(ac.signal); } -async function cmdAsk(positionals: string[]) { - const question = positionals.join(" ").trim(); - if (!question) { - console.error("Usage: fortytwo ask "); - process.exit(1); - } - +async function loadClient(): Promise<{ client: FortyTwoClient; nodeId: string }> { if (!configExists()) { console.error("No config found. Run 'setup' or 'import' first."); process.exit(1); } - const cfg = getConfig(); const identity = loadIdentity(cfg.node_identity_file); if (!identity) { console.error("No identity found. Run 'setup' or 'import' first."); process.exit(1); } - const client = new FortyTwoClient(); await client.login(identity.node_id, identity.node_secret); + return { client, nodeId: identity.node_id }; +} + +async function cmdAsk(positionals: string[]) { + const question = positionals.join(" ").trim(); + if (!question) { + console.error("Usage: fortytwo ask "); + process.exit(1); + } + + const { client, nodeId } = await loadClient(); + + // Pre-check: Challenger nodes cannot create queries. + try { + const cap = await client.getCapability(nodeId); + if (cap.node_tier !== "capable") { + console.error( + `You are still a Challenger (rank ${cap.capability_rank}/42). ` + + `Reach Capability 42 by answering challenges first.`, + ); + process.exit(1); + return; + } + } catch (err) { + // Old server (404) — fall through to createQuery which will succeed under old rules. + if (!(err instanceof ApiError && err.status === 404)) throw err; + } const encrypted = Buffer.from(question, "utf-8").toString("base64"); - const res = await client.createQuery(encrypted, "general"); - console.log(`Question submitted! ID: ${res.id ?? "?"}`); + try { + const res = await client.createQuery(encrypted, "general"); + console.log(`Question submitted! ID: ${res.id ?? "?"}`); + } catch (err) { + if (err instanceof ApiError && err.status === 403) { + console.error( + "You are still a Challenger. Reach Capability 42 by answering challenges first.", + ); + process.exit(1); + } + throw err; + } +} + +async function cmdCapability(positionals: string[]) { + const sub = positionals[0]; + const { client, nodeId } = await loadClient(); + + if (sub === "history") { + const history = await client.getCapabilityHistory(nodeId, 1, 20); + if (history.items.length === 0) { + console.log("No capability changes recorded."); + return; + } + console.log(`Capability history (${history.total} total):`); + for (const entry of history.items) { + const sign = entry.delta > 0 ? "+" : ""; + console.log( + ` ${entry.created_at} ${sign}${entry.delta} (${entry.rank_before}→${entry.rank_after}) — ${entry.reason}`, + ); + } + return; + } + + if (sub && sub !== "show") { + console.error("Usage: fortytwo capability [show|history]"); + process.exit(1); + } + + const cap = await client.getCapability(nodeId); + console.log(`Node tier: ${cap.node_tier}`); + console.log(`Capability: ${cap.capability_rank}/42`); + console.log(`Dead locked: ${cap.is_dead_locked ? "yes" : "no"}`); +} + +async function cmdReset(flags: Record) { + const { client, nodeId } = await loadClient(); + if (!flags.yes && !flags.y) { + console.log( + `This will reset agent ${nodeId} to Capability 0 and drop 250 FOR into challenge_locked.`, + ); + console.log("Re-run with --yes to confirm."); + return; + } + const result = await resetAccount(client, console.log); + console.log( + `✓ Reset applied — rank ${result.rank_before}→0, +${result.drop_amount} FOR locked.`, + ); +} + +async function cmdChallenge(positionals: string[]) { + const sub = positionals[0]; + const { client } = await loadClient(); + + if (!sub || sub === "list") { + const page = await client.listActiveChallengeRounds(1, 50); + if (page.items.length === 0) { + console.log("No active challenge rounds."); + return; + } + console.log(`Active challenge rounds (${page.items.length}):`); + for (const r of page.items) { + const answered = r.has_answered ? " [answered]" : ""; + console.log(` ${r.id} ends ${r.ends_at} reward ${r.reward_per_winner} FOR${answered}`); + } + return; + } + + if (sub === "answer") { + const roundId = positionals[1]; + const answer = positionals.slice(2).join(" "); + if (!roundId || !answer) { + console.error("Usage: fortytwo challenge answer "); + process.exit(1); + return; + } + const response = await client.submitChallengeAnswer(roundId, answer); + console.log( + `✓ Answer submitted — id=${response.id}, staked ${response.staked_amount} FOR`, + ); + return; + } + + console.error("Usage: fortytwo challenge [list|answer ]"); + process.exit(1); } function cmdConfig(positionals: string[]) { @@ -387,7 +499,11 @@ Usage: fortytwo setup [flags] Register new node fortytwo import [flags] Import existing node fortytwo run [-v] Run node (headless) - fortytwo ask Submit a question + fortytwo ask Submit a question (Capable only) + fortytwo capability [history] Show capability rank / tier (or history) + fortytwo reset --yes Reset capability to 0 (+250 FOR locked) + fortytwo challenge list List active Capability Challenge rounds + fortytwo challenge answer Submit manual answer to a round fortytwo config show Show config fortytwo config set Update config fortytwo identity Show node credentials @@ -449,6 +565,15 @@ async function run() { case "ask": await cmdAsk(positionals); break; + case "capability": + await cmdCapability(positionals); + break; + case "reset": + await cmdReset(flags); + break; + case "challenge": + await cmdChallenge(positionals); + break; case "config": cmdConfig(positionals); break; diff --git a/src/commands.ts b/src/commands.ts index 959f0c2..e41abe8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -35,6 +35,9 @@ const CONFIG_KEYS = [ export const SUGGESTIONS = [ "/help", "/ask ", + "/capability", + "/capability history", + "/challenge list", "/identity", "/profile", "/profile list", @@ -61,7 +64,9 @@ export function executeCommand(input: string): string[] { if (cmd === "help") { return [ "Commands:", - " /ask — submit a question to the network", + " /ask — submit a question (Capable only)", + " /capability [history] — show capability rank / tier / history", + " /challenge list — list active Capability Challenge rounds", " /identity — show node_id and node_secret", " /profile list — list all profiles", " /profile create — create a new profile", diff --git a/src/event-bus.ts b/src/event-bus.ts index 981c3b8..f8f62f4 100644 --- a/src/event-bus.ts +++ b/src/event-bus.ts @@ -96,6 +96,11 @@ export interface ViewerStats { forBalance: string; intelligenceNormalized: string; judgingNormalized: string; + challengeLocked: number; + capabilityRank: number | null; + nodeTier: "challenger" | "capable" | null; + isDeadLocked: boolean; + challengeRoundsAvailable: number; } export interface ViewerConfig { @@ -152,6 +157,11 @@ function defaultStats(): ViewerStats { forBalance: "0", intelligenceNormalized: "0", judgingNormalized: "0", + challengeLocked: 0, + capabilityRank: null, + nodeTier: null, + isDeadLocked: false, + challengeRoundsAvailable: 0, }; } @@ -343,6 +353,22 @@ class ViewerEventBus extends EventEmitter { Object.assign(this._config, cfg); this._emit({ type: "config_update", data: { ...this._config } }); } + + setCapability( + capabilityRank: number, + nodeTier: "challenger" | "capable", + isDeadLocked = false, + ): void { + this._stats.capabilityRank = capabilityRank; + this._stats.nodeTier = nodeTier; + this._stats.isDeadLocked = isDeadLocked; + this.broadcastStats(); + } + + setChallengeRoundsAvailable(count: number): void { + this._stats.challengeRoundsAvailable = count; + this.broadcastStats(); + } } const g = globalThis as typeof globalThis & { __viewerBus?: ViewerEventBus }; diff --git a/src/identity.ts b/src/identity.ts index f9d29ad..194ea18 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -1,11 +1,8 @@ import { generateKeyPairSync } from "node:crypto"; import { readFileSync, writeFileSync, existsSync } from "node:fs"; import * as config from "./config.js"; -import { sleep, mapWithConcurrency } from "./utils.js"; -import { FortyTwoClient } from "./api-client.js"; -import * as llm from "./llm.js"; - -const MAX_TIEBREAK_ATTEMPTS = 5; +import type { FortyTwoClient } from "./api-client.js"; +import type { ResetResponse } from "./api-types.js"; export type LogFn = (msg: string) => void; @@ -43,240 +40,51 @@ export function loadIdentity(path: string): Identity | null { } } -interface Challenge { - id: string | number; - question: string; - option_a: string; - option_b: string; -} - -interface ChallengeResponse { - challenge_id: string; - choice: number; -} - -async function solveChallenges(challenges: Challenge[], log: LogFn): Promise { - const total = challenges.length; - let compared = 0; - let solved = 0; - const concurrency = config.get().llm_concurrency; - - // Phase 1: Run forward (a,b) + inverse (b,a) concurrently for all challenges - const CHALLENGE_TIMEOUT = 120_000; // 2 min per challenge - - const pairResults = await mapWithConcurrency( - challenges, - concurrency, - async (ch, idx): Promise<[number, Challenge, number]> => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), CHALLENGE_TIMEOUT); - try { - const [forward, inverse] = await Promise.all([ - llm.compareForRegistration(ch.question, ch.option_a, ch.option_b, controller.signal), - llm.compareForRegistration(ch.question, ch.option_b, ch.option_a, controller.signal), - ]); - const result = forward + -inverse; - compared++; - if (result !== 0) solved++; - const { active, max } = llm.getLlmConcurrency(); - log(`~↳ Comparing: ${compared}/${total} (${solved} settled) [LLM ${active}/${max}]`); - return [idx, ch, result]; - } catch { - compared++; - const { active, max } = llm.getLlmConcurrency(); - log(`~↳ Comparing: ${compared}/${total} (${solved} settled) [LLM ${active}/${max}]`); - return [idx, ch, 0]; - } finally { - clearTimeout(timeout); - } - }, - ); - - // Collect resolved and unresolved - const responses = new Map(); - const unresolved: [number, Challenge, number][] = []; - - for (const [idx, ch, net] of pairResults) { - if (net > 0) { - responses.set(idx, { challenge_id: String(ch.id), choice: 0 }); - } else if (net < 0) { - responses.set(idx, { challenge_id: String(ch.id), choice: 1 }); - } else { - unresolved.push([idx, ch, net]); - } - } - - // Phase 2: Sequential tiebreak for unresolved challenges - for (let [idx, ch, net] of unresolved) { - for (let tb = 1; tb <= MAX_TIEBREAK_ATTEMPTS; tb++) { - let score: number; - if (Math.random() < 0.5) { - score = await llm.compareForRegistration(ch.question, ch.option_a, ch.option_b); - } else { - score = -(await llm.compareForRegistration(ch.question, ch.option_b, ch.option_a)); - } - net += score; - if (net !== 0) break; - } - - let choice: number; - if (net > 0) choice = 0; - else if (net < 0) choice = 1; - else choice = Math.random() < 0.5 ? 0 : 1; - - responses.set(idx, { challenge_id: String(ch.id), choice }); - solved++; - const { active, max } = llm.getLlmConcurrency(); - log(`~↳ Solving: ${solved}/${total} [LLM ${active}/${max}]`); - } - - // Return in original order - return Array.from({ length: total }, (_, i) => responses.get(i)!); -} - +/** + * Register a new agent using the 1-step registration flow (TZ-001). + * The server returns `agent_id`, `secret`, `capability_rank` (0) and + * `node_tier` ("challenger") directly — no pairwise challenge quiz. + */ export async function registerAgent( client: FortyTwoClient, displayName = "JudgeNode", log: LogFn = console.log, ): Promise { - let attempt = 0; - - while (true) { - attempt++; - log(`Registering "${displayName}"`); - log(`↳ Attempt ${attempt}`); - - const { privatePem, publicPem } = generateRsaKeypair(); - - try { - const challengeData = await client.register(publicPem, displayName); - const sessionId = challengeData.challenge_session_id as string; - const challenges = challengeData.challenges as Challenge[]; - const requiredCorrect = (challengeData.required_correct as number) ?? 17; + log(`Registering "${displayName}"...`); - log(`~Solving: 0/${challenges.length}`); + const { privatePem, publicPem } = generateRsaKeypair(); + const response = await client.register(publicPem, displayName); - const responses = await solveChallenges(challenges, log); - log(`↳ Submitting answers (need ${requiredCorrect} correct)...`); - const result = await client.completeRegistration(sessionId, responses); - - if (!result.passed) { - const correct = result.correct_count ?? 0; - log(`✕ Attempt ${attempt}: ${correct}/${challenges.length} correct (need ${requiredCorrect})`); - log(`↳ Retrying in 2s...`); - await sleep(2000); - continue; - } - - const nodeId = String(result.agent_id); - const correct = result.correct_count ?? challenges.length; - const node_secret = result.secret as string; - - const identity: Identity = { - node_id: nodeId, - node_secret, - public_key_pem: publicPem, - private_key_pem: privatePem, - }; - saveIdentity(config.get().node_identity_file, identity); - log(`✓ Passed! ${correct}/${challenges.length} correct — Node ID: ${nodeId}`); - - return identity; - } catch (err) { - log(`✕ Attempt ${attempt}: ${err}`); - log(`↳ Retrying in 5s...`); - await sleep(5000); - } + if (!response?.agent_id || !response?.secret) { + throw new Error( + `Registration failed — server did not return agent_id/secret (got keys: ${ + response ? Object.keys(response).join(", ") : "none" + }). The server may be running the legacy 2-step flow; check fortytwo_api_base.`, + ); } -} - -export async function reactivateAccount( - client: FortyTwoClient, - nodeId: string, - nodeSecret: string, - log: LogFn = console.log, -): Promise { - let attempt = 0; - - while (true) { - attempt++; - log(`↳ Reactivation attempt ${attempt}`); - - try { - const challengeData = await client.startReactivation(nodeId, nodeSecret); - const sessionId = challengeData.challenge_session_id as string; - const challenges = challengeData.challenges as Challenge[]; - const requiredCorrect = (challengeData.required_correct as number) ?? 17; - log(`~Solving: 0/${challenges.length}`); + const identity: Identity = { + node_id: response.agent_id, + node_secret: response.secret, + public_key_pem: publicPem, + private_key_pem: privatePem, + }; + saveIdentity(config.get().node_identity_file, identity); + log(`✓ Registered — Node ID: ${response.agent_id} (tier: ${response.node_tier}, rank: ${response.capability_rank})`); - const responses = await solveChallenges(challenges, log); - log(`↳ Submitting answers (need ${requiredCorrect} correct)...`); - const result = await client.completeReactivation(sessionId, responses); - - if (!result.passed) { - const correct = result.correct_count ?? 0; - log(`✕ Failed: ${correct}/${challenges.length} correct`); - log(`↳ Retrying in 5s...`); - await sleep(5000); - continue; - } - - log(`✓ Reactivation successful! (attempt ${attempt})`); - return; - } catch (err) { - log(`✕ Reactivation attempt ${attempt}: ${err}`); - log(`↳ Retrying in 10s...`); - await sleep(10_000); - } - } + return identity; } +/** + * Reset the node's capability rank. The server performs a one-shot reset + * (no challenge quiz) and drops FOR into `challenge_locked`. + */ export async function resetAccount( client: FortyTwoClient, log: LogFn = console.log, -): Promise { - let attempt = 0; - - while (true) { - attempt++; - log(`↳ Reset attempt ${attempt}`); - - try { - const challengeData = await client.startAccountReset(); - const sessionId = challengeData.challenge_session_id as string; - const challenges = challengeData.challenges as Challenge[]; - const requiredCorrect = (challengeData.required_correct as number) ?? 17; - const cooldownMinutes = (challengeData.cooldown_minutes as number) ?? 10; - - log(`~Solving: 0/${challenges.length}`); - - const responses = await solveChallenges(challenges, log); - log(`↳ Submitting answers (need ${requiredCorrect} correct)...`); - const result = await client.completeAccountReset(sessionId, responses); - - if (!result.passed) { - const correct = result.correct_count ?? 0; - const waitTime = Math.max(cooldownMinutes * 60 * 1000, 5000); - log(`✕ Failed: ${correct}/${challenges.length} correct`); - log(`↳ Waiting...`); - await sleep(waitTime); - continue; - } - - log(`✓ Reset successful! (attempt ${attempt})`); - return; - } catch (err) { - const msg = String(err).toLowerCase(); - if (msg.includes("cooldown") || msg.includes("limited")) { - log(`✕ Reset attempt ${attempt} hit cooldown`); - log(`↳ Waiting 10 min...`); - await sleep(600_000); - } else { - log(`✕ Reset attempt ${attempt}: ${err}`); - log(`↳ Retrying in 10s...`); - await sleep(10_000); - } - } - } +): Promise { + log(`↳ Resetting capability...`); + const result = await client.resetCapability(); + log(`✓ Reset complete — rank ${result.rank_before} → 0, +${result.drop_amount} FOR locked`); + return result; } diff --git a/src/llm.ts b/src/llm.ts index 42e4fae..a5b2c30 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -81,7 +81,7 @@ export function resetLlmClient(): void { semaphore = null; } -type LlmPurpose = "ranking" | "generation" | "registration" | "other"; +type LlmPurpose = "ranking" | "generation" | "other"; const stats = { calls: 0, @@ -250,43 +250,6 @@ export async function callLlm( return callLlmApi([{ role: "user", content: prompt }], retries, 0.3, signal, purpose); } -export async function compareForRegistration( - question: string, - optionA: string, - optionB: string, - signal?: AbortSignal, -): Promise { - const prompt = - `######Problem######: \n${question}\n` + - `######Solution A######. \n${optionA}\n` + - `######Solution B######. \n${optionB}\n` + - `######Instruction######:\n` + - `Select the best one of the two proposed solutions to the problem. ` + - `THEN end output with best solution overall index (A or B) on the new line ` + - `(Only letter, nothing else).\n` + - `Don't try to re-solve/re-compute/re-think the problem. ` + - `Only find flows/mistakes in a proposed solutions and pick the best one (and that not validated/certified by you to be ideal/fully correct).\n` + - `If both solutions are equal or you cannot determine which is better, output U.\n` + - `######Decision######:`; - - try { - for (let attempt = 0; attempt < 2; attempt++) { - const response = await callLlm(prompt, 2, signal, "registration"); - const letter = parseLastLetter(response, new Set(["A", "B", "U"])); - if (letter !== null) { - if (letter === "A") return 1; - if (letter === "B") return -1; - return 0; - } - } - } catch (err) { - verbose(`✗ Registration comparison failed: ${err}`); - return 0; - } - - return 0; -} - export async function evaluateGoodEnough( problem: string, solution: string, diff --git a/src/main.ts b/src/main.ts index 50016a8..1748fcd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,19 @@ import { createHash } from "node:crypto"; import * as config from "./config.js"; import { sleep, secondsUntilDeadline, setVerbose, log, getRoleLabel } from "./utils.js"; -import { FortyTwoClient } from "./api-client.js"; -import { loadIdentity, resetAccount, reactivateAccount } from "./identity.js"; +import { FortyTwoClient, ApiError } from "./api-client.js"; +import { loadIdentity } from "./identity.js"; import { judgeChallenge } from "./judging.js"; import { answerQuery } from "./answering.js"; import { isLlmBusy } from "./llm.js"; import { validateModel } from "./setup-logic.js"; import { viewerBus, type VisibleQuery } from "./event-bus.js"; +import { + createChallengeContext, + processChallengeRounds, + type ChallengeContext, +} from "./capability-challenge.js"; +import type { CapabilityInfo } from "./api-types.js"; /** Initialize viewer dashboard config and load initial stats from API. */ export async function initViewerBus( @@ -51,17 +57,17 @@ export async function initViewerBus( intelligenceNormalized: String(p.intelligence_normalized ?? "0"), judgingNormalized: String(p.judging_normalized ?? "0"), }); + if (agentData.capability_rank !== undefined && agentData.node_tier) { + viewerBus.setCapability( + Number(agentData.capability_rank), + agentData.node_tier, + false, + ); + } } } catch { /* stats are optional — don't block startup */ } } -export class InsufficientFundsError extends Error { - constructor(message: string) { - super(message); - this.name = "InsufficientFundsError"; - } -} - function shouldAnswer(queryId: string, nodeId: string): boolean { const hash = createHash("sha256").update(queryId + nodeId).digest("hex"); return parseInt(hash.slice(-8), 16) % 2 === 0; @@ -84,6 +90,7 @@ export async function checkBalance(client: FortyTwoClient): Promise { try { const balanceData = await client.getBalance(); const available = parseFloat(balanceData.available ?? "0"); + const challengeLocked = parseFloat(balanceData.challenge_locked ?? "0"); const staked = parseFloat(balanceData.staked ?? "0"); const total = parseFloat(balanceData.total ?? "0"); const weekEarned = parseFloat(balanceData.current_week_earned ?? "0"); @@ -91,6 +98,7 @@ export async function checkBalance(client: FortyTwoClient): Promise { const lifetimeSpent = parseFloat(balanceData.lifetime_spent ?? "0"); viewerBus.updateStats({ energy: available, + challengeLocked, staked, total, weekEarned, @@ -105,23 +113,38 @@ export async function checkBalance(client: FortyTwoClient): Promise { } } +/** + * Fetch the agent's current capability state. Falls back to a "capable" view + * when the server predates TZ-001 (404 on /capability/{id}). + */ +export async function fetchCapability(client: FortyTwoClient): Promise { + try { + const cap = await client.getCapability(); + viewerBus.setCapability(cap.capability_rank, cap.node_tier, cap.is_dead_locked); + return cap; + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + // Old server — treat as capable with unknown rank. + return null; + } + throw err; + } +} + // Track in-flight task IDs to avoid duplicates across cycles const inFlight = new Set(); -const taskStats = { answering: 0, judging: 0 }; +const taskStats = { answering: 0, judging: 0, challenge: 0 }; export function getTaskStats() { return { ...taskStats }; } -function launchTask(id: string, label: string, fn: () => Promise): void { +function launchTask(id: string, label: "answering" | "judging", fn: () => Promise): void { if (inFlight.has(id)) return; inFlight.add(id); fn() - .then(() => { - if (label === "answering") taskStats.answering++; - else if (label === "judging") taskStats.judging++; - }) + .then(() => { taskStats[label]++; }) .catch((err) => log(`[${id.slice(0, 8)}] ${label[0].toUpperCase()}${label.slice(1)} failed: ${(err as Error).message ?? err}`)) .finally(() => inFlight.delete(id)); } @@ -221,11 +244,35 @@ export async function processQueries(client: FortyTwoClient, dualMode = false): return eligible.length; } -export async function runCycle(client: FortyTwoClient): Promise { +/** + * Run one iteration of the worker loop. Dispatches based on `capability`: + * - Capable (rank = 42): process queries and/or challenges by role. + * - Challenger (rank < 42): participate in Capability Challenge rounds. + * - Dead-locked: log a warning, skip all work (manual reset required). + */ +export async function runCycle( + client: FortyTwoClient, + capability: CapabilityInfo | null, + challengeCtx: ChallengeContext, +): Promise { const cfg = config.get(); const role = cfg.node_role; - let total = 0; + if (capability?.is_dead_locked) { + log("Dead lock — no FOR available. Run 'fortytwo reset' to unlock."); + viewerBus.pushError("Dead lock: no FOR available. Reset required."); + return 0; + } + + // Challenger: participate in Capability Challenge rounds. ELO is frozen, so + // we do not run the normal answer/judge loops for Challengers — reaching + // rank 42 requires solving puzzles. + if (capability?.node_tier === "challenger") { + return await processChallengeRounds(challengeCtx); + } + + // Capable (or old server without capability endpoint): normal flow. + let total = 0; if (role === "JUDGE") { total += await processChallenges(client, false); } else if (role === "ANSWERER") { @@ -239,7 +286,6 @@ export async function runCycle(client: FortyTwoClient): Promise { } else { log(`Unknown NODE_ROLE: ${role}`); } - return total; } @@ -288,41 +334,34 @@ export async function main(signal?: AbortSignal): Promise { await client.login(identity.node_id, identity.node_secret); await initViewerBus(client, cfg, identity.node_id); + const challengeCtx = createChallengeContext(client); + log(`✓ Starting polling loop (interval: ${cfg.poll_interval}s)`); let cycles = 0; while (!signal?.aborted) { const cycleStart = Date.now(); try { const available = await checkBalance(client); - if (available < cfg.min_balance) { - throw new InsufficientFundsError( - `Insufficient FOR balance: ${available.toFixed(2)} available, ${cfg.min_balance.toFixed(2)} required`, - ); + const capability = await fetchCapability(client); + + // `min_balance` gates Capable nodes (they spend `available` FOR to stake + // on queries/judgments). Challengers are funded from `challenge_locked` + // instead, so a zero `available` balance is expected and must not block + // their Capability Challenge participation. + const isCapable = capability === null || capability.node_tier === "capable"; + if (isCapable && available < cfg.min_balance) { + const msg = `Low balance: ${available.toFixed(2)} FOR < ${cfg.min_balance.toFixed(2)} required. Worker idle — run 'fortytwo reset --yes' manually.`; + log(msg); + viewerBus.pushError(msg); + } else { + const count = await runCycle(client, capability, challengeCtx); + cycles++; + viewerBus.updateStats({ cycles }); + if (count > 0) log(`Processed ${count} items this cycle`); } - - const count = await runCycle(client); - cycles++; - viewerBus.updateStats({ cycles }); - if (count > 0) log(`Processed ${count} items this cycle`); } catch (err) { if (signal?.aborted) return; - if (err instanceof InsufficientFundsError) { - log(`${err.message} — resetting account...`); - viewerBus.pushError(err.message); - await resetAccount(client); - log("Account reset complete!"); - continue; - } const errMsg = (err as Error).message ?? String(err); - if (errMsg.toLowerCase().includes("inactive") || errMsg.toLowerCase().includes("deactivated")) { - log(`Account deactivated — reactivating...`); - viewerBus.updateStats({ accountInactive: true }); - await reactivateAccount(client, identity.node_id, identity.node_secret); - await client.login(identity.node_id, identity.node_secret); - viewerBus.updateStats({ accountInactive: false }); - log("Reactivation complete!"); - continue; - } log(`Error in polling cycle: ${errMsg}`); viewerBus.pushError(errMsg); } diff --git a/src/onboard.tsx b/src/onboard.tsx index 6061ba5..3190ab3 100644 --- a/src/onboard.tsx +++ b/src/onboard.tsx @@ -296,18 +296,7 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar const displayName = cfg.node_display_name || values.node_name || "JudgeNode"; await registerAgent(client, displayName, (msg) => { if (cancelled) return; - if (msg.startsWith("~")) { - // Replace last line (progress update) - const text = msg.slice(1); - setRegLog((prev) => { - if (prev.length > 0 && prev[prev.length - 1].startsWith("[progress]")) { - return [...prev.slice(0, -1), `[progress]${text}`]; - } - return [...prev, `[progress]${text}`]; - }); - } else { - setRegLog((prev) => [...prev, msg]); - } + setRegLog((prev) => [...prev, msg]); }); if (cancelled) return; @@ -449,7 +438,6 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar } if (phase === "registering" || phase === "importing") { - const displayLine = (line: string) => line.replace(/^\[progress]/, ""); const last = regLog.length - 1; const header = phase === "importing" ? "IMPORT NODE" : "REGISTRATION"; @@ -460,10 +448,9 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar {regLog.map((line, i) => { const isCurrent = i === last; - const text = displayLine(line); return ( - {isCurrent ? {loader} : " "}{text} + {isCurrent ? {loader} : " "}{line} ); })} diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts index ae1c8bf..53375a3 100644 --- a/tests/api-client.test.ts +++ b/tests/api-client.test.ts @@ -104,17 +104,25 @@ describe("FortyTwoClient", () => { await expect(client.request("GET", "/test", { auth: false })).rejects.toThrow("Intelligence rank too low"); }); - it("register sends public key", async () => { - mockFetch({ challenge_session_id: "sess-1", challenges: [] }); + it("register returns 1-step payload", async () => { + mockFetch({ + agent_id: "a1", + secret: "s1", + capability_rank: 0, + node_tier: "challenger", + message: "ok", + }); const client = new FortyTwoClient("https://api.test.com"); const data = await client.register("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----", "MyBot"); - expect(data.challenge_session_id).toBe("sess-1"); + expect(data.agent_id).toBe("a1"); + expect(data.node_tier).toBe("challenger"); + expect(data.capability_rank).toBe(0); const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body); expect(body.display_name).toBe("MyBot"); }); it("register without displayName omits it", async () => { - mockFetch({ challenge_session_id: "sess-1", challenges: [] }); + mockFetch({ agent_id: "a1", secret: "s1", capability_rank: 0, node_tier: "challenger" }); const client = new FortyTwoClient("https://api.test.com"); await client.register("pubkey"); const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body); @@ -195,15 +203,6 @@ describe("FortyTwoClient", () => { await expect(client.request("GET", "/test", { auth: false, maxRetries: 1 })).rejects.toThrow("ECONNREFUSED"); }); - it("completeRegistration sends session and responses", async () => { - mockFetch({ passed: true, agent_id: "a1", secret: "s1" }); - const client = new FortyTwoClient("https://api.test.com"); - const result = await client.completeRegistration("sess-1", [{ challenge_id: "c1", choice: 0 }]); - expect(result.passed).toBe(true); - const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body); - expect(body.challenge_session_id).toBe("sess-1"); - }); - it("getBalance calls correct endpoint", async () => { mockFetch({ tokens: { access_token: "at", refresh_token: "rt", expires_in: 900 } }); const client = new FortyTwoClient("https://api.test.com"); @@ -270,14 +269,69 @@ describe("FortyTwoClient", () => { expect(body.good_answers).toEqual(["a1"]); }); - it("startAccountReset calls correct endpoint", async () => { + it("getCapability calls correct endpoint", async () => { mockFetch({ tokens: { access_token: "at", refresh_token: "rt", expires_in: 900 } }); const client = new FortyTwoClient("https://api.test.com"); await client.login("agent-1", "secret"); + mockFetch({ + agent_id: "agent-1", + capability_rank: 21, + node_tier: "challenger", + is_dead_locked: false, + }); + const data = await client.getCapability(); + expect(data.capability_rank).toBe(21); + expect(data.node_tier).toBe("challenger"); + expect((globalThis.fetch as any).mock.calls[0][0]).toContain("/capability/agent-1"); + }); - mockFetch({ challenge_session_id: "sess" }); - const data = await client.startAccountReset(); - expect(data.challenge_session_id).toBe("sess"); + it("resetCapability calls correct endpoint", async () => { + mockFetch({ tokens: { access_token: "at", refresh_token: "rt", expires_in: 900 } }); + const client = new FortyTwoClient("https://api.test.com"); + await client.login("agent-1", "secret"); + mockFetch({ + agent_id: "agent-1", + capability_rank: 0, + rank_before: 30, + challenge_locked: "250", + drop_amount: "250", + }); + const data = await client.resetCapability(); + expect(data.rank_before).toBe(30); + expect(data.drop_amount).toBe("250"); + expect((globalThis.fetch as any).mock.calls[0][0]).toContain("/capability/agent-1/reset"); + expect((globalThis.fetch as any).mock.calls[0][1].method).toBe("POST"); + }); + + it("listActiveChallengeRounds paginates correctly", async () => { + mockFetch({ tokens: { access_token: "at", refresh_token: "rt", expires_in: 900 } }); + const client = new FortyTwoClient("https://api.test.com"); + await client.login("agent-1", "secret"); + mockFetch({ items: [{ id: "r1", content: "Y/N?" }], total: 1, page: 1, page_size: 20 }); + const data = await client.listActiveChallengeRounds(1, 20); + expect(data.items[0].id).toBe("r1"); + expect((globalThis.fetch as any).mock.calls[0][0]).toContain("/foundation-pool/rounds"); + expect((globalThis.fetch as any).mock.calls[0][0]).toContain("page=1"); + }); + + it("submitChallengeAnswer sends content", async () => { + mockFetch({ tokens: { access_token: "at", refresh_token: "rt", expires_in: 900 } }); + const client = new FortyTwoClient("https://api.test.com"); + await client.login("agent-1", "secret"); + mockFetch({ id: "ans-1", staked_amount: "10" }); + const data = await client.submitChallengeAnswer("round-1", "Yes"); + expect(data.id).toBe("ans-1"); + const body = JSON.parse((globalThis.fetch as any).mock.calls[0][1].body); + expect(body.content).toBe("Yes"); + }); + + it("throws ApiError with status on 403", async () => { + mockFetch({ tokens: { access_token: "at", refresh_token: "rt", expires_in: 900 } }); + const client = new FortyTwoClient("https://api.test.com"); + await client.login("agent-1", "secret"); + mockFetch({ detail: "Challenger nodes cannot create queries." }, 403); + const { ApiError } = await import("../src/api-client.js"); + await expect(client.createQuery("ciphertext", "general")).rejects.toBeInstanceOf(ApiError); }); it("throws after all retries exhausted", async () => { @@ -383,15 +437,6 @@ describe("FortyTwoClient", () => { expect(data.decrypted_content).toBe("What?"); }); - it("completeAccountReset sends session and responses", async () => { - mockFetch({ tokens: { access_token: "at", refresh_token: "rt", expires_in: 900 } }); - const client = new FortyTwoClient("https://api.test.com"); - await client.login("agent-1", "secret"); - mockFetch({ passed: true }); - const data = await client.completeAccountReset("sess-1", [{ challenge_id: "c1", choice: 0 }]); - expect(data.passed).toBe(true); - }); - it("getAgentStats calls correct endpoint", async () => { mockFetch({ tokens: { access_token: "at", refresh_token: "rt", expires_in: 900 } }); const client = new FortyTwoClient("https://api.test.com"); diff --git a/tests/capability-challenge.test.ts b/tests/capability-challenge.test.ts new file mode 100644 index 0000000..5657153 --- /dev/null +++ b/tests/capability-challenge.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockLlm, mockBus, mockUtils } = vi.hoisted(() => ({ + mockLlm: { + generateAnswer: vi.fn().mockResolvedValue("Yes"), + }, + mockBus: { + setState: vi.fn(), + setChallengeRoundsAvailable: vi.fn(), + }, + mockUtils: { + log: vi.fn(), + pinTask: vi.fn(), + unpinTask: vi.fn(), + }, +})); + +vi.mock("../src/llm.js", () => mockLlm); +vi.mock("../src/event-bus.js", () => ({ viewerBus: mockBus })); +vi.mock("../src/utils.js", () => mockUtils); + +vi.mock("../src/api-client.js", () => { + class MockApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } + } + return { + ApiError: MockApiError, + FortyTwoClient: class {}, + }; +}); + +import { + processChallengeRounds, + createChallengeContext, +} from "../src/capability-challenge.js"; +import { ApiError } from "../src/api-client.js"; + +// In 2026-04-13 the test is pinned to a future ends_at +const FAR_FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString(); +const NEAR_FUTURE = new Date(Date.now() + 5_000).toISOString(); + +function buildRound(overrides: Record = {}) { + return { + id: "round-aaaaaaaa", + foundation_pool_id: "fp-1", + content: "Is sky blue?", + status: "active", + starts_at: new Date(Date.now() - 1000).toISOString(), + ends_at: FAR_FUTURE, + for_budget_total: "100", + settled_at: null, + winners_count: 0, + reward_per_winner: "10", + created_at: new Date(Date.now() - 1000).toISOString(), + answer_count: 0, + has_answered: false, + ...overrides, + }; +} + +function makeClient(partial: Record = {}) { + return { + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [], total: 0, page: 1, page_size: 20, + }), + submitChallengeAnswer: vi.fn().mockResolvedValue({ + id: "ans-1", + round_id: "round-aaaaaaaa", + agent_id: "agent-1", + content: "Yes", + is_correct: null, + capability_delta: 0, + staked_amount: "10", + reward_amount: "0", + submitted_at: new Date().toISOString(), + validated_at: null, + }), + ...partial, + } as any; +} + +describe("processChallengeRounds", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 0 with no side effects when no rounds", async () => { + const client = makeClient(); + const ctx = createChallengeContext(client); + const count = await processChallengeRounds(ctx); + expect(count).toBe(0); + expect(mockLlm.generateAnswer).not.toHaveBeenCalled(); + expect(client.submitChallengeAnswer).not.toHaveBeenCalled(); + expect(mockBus.setChallengeRoundsAvailable).toHaveBeenCalledWith(0); + }); + + it("filters out already-answered rounds", async () => { + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [buildRound({ has_answered: true })], + total: 1, + page: 1, + page_size: 20, + }), + }); + const ctx = createChallengeContext(client); + const count = await processChallengeRounds(ctx); + expect(count).toBe(0); + expect(mockLlm.generateAnswer).not.toHaveBeenCalled(); + expect(client.submitChallengeAnswer).not.toHaveBeenCalled(); + expect(mockBus.setChallengeRoundsAvailable).toHaveBeenCalledWith(0); + }); + + it("filters out non-active rounds", async () => { + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [buildRound({ status: "settled" })], + total: 1, + page: 1, + page_size: 20, + }), + }); + const ctx = createChallengeContext(client); + const count = await processChallengeRounds(ctx); + expect(count).toBe(0); + expect(mockLlm.generateAnswer).not.toHaveBeenCalled(); + }); + + it("skips rounds already in-flight", async () => { + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [buildRound({ id: "busy-round" })], + total: 1, + page: 1, + page_size: 20, + }), + }); + const ctx = createChallengeContext(client); + ctx.inFlight.add("busy-round"); + const count = await processChallengeRounds(ctx); + expect(count).toBe(0); + expect(mockLlm.generateAnswer).not.toHaveBeenCalled(); + expect(client.submitChallengeAnswer).not.toHaveBeenCalled(); + // busy-round was already there; processChallengeRounds must not delete + // entries it did not add itself. + expect(ctx.inFlight.has("busy-round")).toBe(true); + }); + + it("skips rounds whose deadline is less than 30s away", async () => { + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [buildRound({ ends_at: NEAR_FUTURE })], + total: 1, + page: 1, + page_size: 20, + }), + }); + const ctx = createChallengeContext(client); + const count = await processChallengeRounds(ctx); + // Attempted, but work inside answerChallengeRound early-returns. + expect(count).toBe(1); + expect(mockLlm.generateAnswer).not.toHaveBeenCalled(); + expect(client.submitChallengeAnswer).not.toHaveBeenCalled(); + expect(ctx.inFlight.size).toBe(0); + }); + + it("generates an answer and submits it on the happy path", async () => { + const round = buildRound(); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [round], total: 1, page: 1, page_size: 20, + }), + }); + const ctx = createChallengeContext(client); + + const count = await processChallengeRounds(ctx); + + expect(count).toBe(1); + expect(mockLlm.generateAnswer).toHaveBeenCalledTimes(1); + expect(mockLlm.generateAnswer).toHaveBeenCalledWith( + expect.stringContaining("logic puzzle"), + round.content, + ); + expect(client.submitChallengeAnswer).toHaveBeenCalledWith(round.id, "Yes"); + expect(mockUtils.pinTask).toHaveBeenCalledWith(round.id, expect.any(String)); + expect(mockUtils.unpinTask).toHaveBeenCalledWith(round.id); + expect(ctx.inFlight.size).toBe(0); + }); + + it("keeps processing subsequent rounds after a submit error", async () => { + const goodRound = buildRound({ id: "good-round" }); + const badRound = buildRound({ id: "bad-round" }); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [badRound, goodRound], total: 2, page: 1, page_size: 20, + }), + submitChallengeAnswer: vi.fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ id: "ans-good", staked_amount: "10" }), + }); + const ctx = createChallengeContext(client); + + const count = await processChallengeRounds(ctx); + + expect(count).toBe(2); + expect(client.submitChallengeAnswer).toHaveBeenCalledTimes(2); + expect(ctx.inFlight.size).toBe(0); + expect(mockUtils.log).toHaveBeenCalledWith(expect.stringContaining("failed")); + }); + + it("returns 0 gracefully on 404 (old server without Foundation Pool)", async () => { + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockRejectedValue(new ApiError(404, "Not found")), + }); + const ctx = createChallengeContext(client); + const count = await processChallengeRounds(ctx); + expect(count).toBe(0); + expect(mockBus.setChallengeRoundsAvailable).toHaveBeenCalledWith(0); + expect(mockLlm.generateAnswer).not.toHaveBeenCalled(); + }); + + it("stops processing further rounds once the node transitions to Capable mid-cycle", async () => { + const r1 = buildRound({ id: "r1" }); + const r2 = buildRound({ id: "r2" }); + const r3 = buildRound({ id: "r3" }); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [r1, r2, r3], total: 3, page: 1, page_size: 20, + }), + submitChallengeAnswer: vi.fn() + .mockResolvedValueOnce({ id: "ans-1", staked_amount: "10" }) // r1 succeeds + .mockRejectedValueOnce(new Error("Capable nodes cannot participate in Capability Challenge rounds")) // r2 tier mismatch + .mockResolvedValueOnce({ id: "ans-3", staked_amount: "10" }), // r3 — should NOT be reached + }); + const ctx = createChallengeContext(client); + + const count = await processChallengeRounds(ctx); + + // Attempted r1 + r2, aborted before r3. + expect(count).toBe(2); + expect(client.submitChallengeAnswer).toHaveBeenCalledTimes(2); + expect(ctx.inFlight.size).toBe(0); + expect(mockUtils.log).toHaveBeenCalledWith( + expect.stringContaining("Reached Capability 42"), + ); + }); + + it("rethrows non-404 listActiveChallengeRounds errors", async () => { + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockRejectedValue(new ApiError(500, "boom")), + }); + const ctx = createChallengeContext(client); + await expect(processChallengeRounds(ctx)).rejects.toThrow("boom"); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 662a3dc..8672a85 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -27,6 +27,28 @@ const mockClient = { login: vi.fn().mockResolvedValue({}), getAgent: vi.fn().mockResolvedValue({ profile: { node_display_name: "Bot" } }), createQuery: vi.fn().mockResolvedValue({ id: "q-1" }), + getCapability: vi.fn().mockResolvedValue({ + agent_id: "agent-1", + capability_rank: 42, + node_tier: "capable", + is_dead_locked: false, + }), + resetCapability: vi.fn().mockResolvedValue({ + agent_id: "agent-1", + capability_rank: 0, + rank_before: 30, + challenge_locked: "250", + drop_amount: "250", + }), + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [], total: 0, page: 1, page_size: 20, + }), + submitChallengeAnswer: vi.fn().mockResolvedValue({ + id: "ans-1", staked_amount: "10", + }), + getCapabilityHistory: vi.fn().mockResolvedValue({ + items: [], total: 0, page: 1, page_size: 20, + }), }; vi.mock("../src/api-client.js", () => { @@ -35,14 +57,34 @@ vi.mock("../src/api-client.js", () => { login = mockClient.login; getAgent = mockClient.getAgent; createQuery = mockClient.createQuery; + getCapability = mockClient.getCapability; + resetCapability = mockClient.resetCapability; + listActiveChallengeRounds = mockClient.listActiveChallengeRounds; + submitChallengeAnswer = mockClient.submitChallengeAnswer; + getCapabilityHistory = mockClient.getCapabilityHistory; + } + class MockApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } } - return { FortyTwoClient: MockFortyTwoClient }; + return { FortyTwoClient: MockFortyTwoClient, ApiError: MockApiError }; }); vi.mock("../src/identity.js", () => ({ loadIdentity: vi.fn().mockReturnValue({ node_id: "agent-1", node_secret: "sec" }), saveIdentity: vi.fn(), registerAgent: vi.fn().mockResolvedValue({ node_id: "new-agent", node_secret: "new-sec" }), + resetAccount: vi.fn().mockResolvedValue({ + agent_id: "agent-1", + capability_rank: 0, + rank_before: 30, + challenge_locked: "250", + drop_amount: "250", + }), })); vi.mock("../src/main.js", () => ({ @@ -340,6 +382,169 @@ describe("cli", () => { await runCli(["ask", "test"]); expect(exitSpy).toHaveBeenCalledWith(1); }); + + it("blocks Challenger via capability pre-check", async () => { + mockClient.getCapability.mockResolvedValue({ + agent_id: "agent-1", + capability_rank: 10, + node_tier: "challenger", + is_dead_locked: false, + }); + await runCli(["ask", "What"]); + expect(mockClient.createQuery).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + const errOut = errorSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(errOut).toContain("Challenger"); + }); + + it("falls back gracefully on 404 from getCapability (old server)", async () => { + const { ApiError } = await import("../src/api-client.js"); + mockClient.getCapability.mockRejectedValue(new ApiError(404, "not found")); + await runCli(["ask", "What"]); + expect(mockClient.createQuery).toHaveBeenCalled(); + }); + + it("surfaces 403 from createQuery as friendly message", async () => { + mockClient.getCapability.mockResolvedValue({ + agent_id: "agent-1", + capability_rank: 42, + node_tier: "capable", + is_dead_locked: false, + }); + const { ApiError } = await import("../src/api-client.js"); + mockClient.createQuery.mockRejectedValue(new ApiError(403, "Challenger nodes cannot create queries.")); + await runCli(["ask", "Hi"]); + expect(exitSpy).toHaveBeenCalledWith(1); + const errOut = errorSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(errOut).toContain("Challenger"); + }); + }); + + describe("capability command", () => { + it("prints tier and rank", async () => { + mockClient.getCapability.mockResolvedValue({ + agent_id: "agent-1", + capability_rank: 21, + node_tier: "challenger", + is_dead_locked: false, + }); + await runCli(["capability"]); + const out = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(out).toContain("challenger"); + expect(out).toContain("21/42"); + expect(out).toContain("Dead locked: no"); + }); + + it("prints history", async () => { + mockClient.getCapabilityHistory.mockResolvedValue({ + items: [ + { + id: "h1", + agent_id: "agent-1", + delta: 3, + rank_before: 10, + rank_after: 13, + reason: "challenge_correct", + reference_id: null, + created_at: "2026-04-13T10:00:00Z", + }, + ], + total: 1, + page: 1, + page_size: 20, + }); + await runCli(["capability", "history"]); + const out = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(out).toContain("+3"); + expect(out).toContain("10→13"); + expect(out).toContain("challenge_correct"); + }); + + it("prints a message when history is empty", async () => { + mockClient.getCapabilityHistory.mockResolvedValue({ + items: [], total: 0, page: 1, page_size: 20, + }); + await runCli(["capability", "history"]); + const out = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(out).toContain("No capability changes"); + }); + + it("exits on unknown capability sub", async () => { + await runCli(["capability", "bogus"]); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("reset command", () => { + it("prints confirmation prompt without --yes and does not reset", async () => { + const { resetAccount } = await import("../src/identity.js"); + await runCli(["reset"]); + expect(resetAccount).not.toHaveBeenCalled(); + const out = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(out).toContain("--yes"); + }); + + it("calls resetAccount with --yes", async () => { + const { resetAccount } = await import("../src/identity.js"); + await runCli(["reset", "--yes"]); + expect(resetAccount).toHaveBeenCalled(); + }); + }); + + describe("challenge command", () => { + it("list prints rounds", async () => { + mockClient.listActiveChallengeRounds.mockResolvedValue({ + items: [{ + id: "round-1", + foundation_pool_id: "fp-1", + content: "?", + status: "active", + starts_at: "2026-04-13T10:00:00Z", + ends_at: "2026-04-13T12:00:00Z", + for_budget_total: "100", + settled_at: null, + winners_count: 0, + reward_per_winner: "10", + created_at: "2026-04-13T10:00:00Z", + has_answered: false, + }], + total: 1, page: 1, page_size: 20, + }); + await runCli(["challenge", "list"]); + const out = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(out).toContain("round-1"); + expect(out).toContain("10 FOR"); + }); + + it("list says so when no rounds", async () => { + mockClient.listActiveChallengeRounds.mockResolvedValue({ + items: [], total: 0, page: 1, page_size: 20, + }); + await runCli(["challenge", "list"]); + const out = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(out).toContain("No active challenge rounds"); + }); + + it("answer submits an answer", async () => { + mockClient.submitChallengeAnswer.mockResolvedValue({ + id: "ans-1", staked_amount: "10", round_id: "r1", agent_id: "a", + content: "Yes", is_correct: null, capability_delta: 0, + reward_amount: "0", submitted_at: "", validated_at: null, + }); + await runCli(["challenge", "answer", "r1", "Yes"]); + expect(mockClient.submitChallengeAnswer).toHaveBeenCalledWith("r1", "Yes"); + }); + + it("answer exits when round_id or answer missing", async () => { + await runCli(["challenge", "answer"]); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockClient.submitChallengeAnswer).not.toHaveBeenCalled(); + }); + + it("exits on unknown challenge sub", async () => { + await runCli(["challenge", "bogus"]); + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); describe("unknown command", () => { diff --git a/tests/identity.test.ts b/tests/identity.test.ts index f44059f..971e949 100644 --- a/tests/identity.test.ts +++ b/tests/identity.test.ts @@ -8,31 +8,18 @@ vi.mock("node:fs", () => ({ vi.mock("../src/config.js", () => ({ get: () => ({ - identity_file: "/tmp/identity.json", - llm_concurrency: 5, - }), -})); - -vi.mock("../src/llm.js", () => ({ - compareForRegistration: vi.fn().mockResolvedValue(1), - getLlmConcurrency: vi.fn().mockReturnValue({ active: 0, max: 5 }), -})); - -vi.mock("../src/utils.js", () => ({ - sleep: vi.fn().mockResolvedValue(undefined), - mapWithConcurrency: vi.fn(async (items: any[], _limit: number, fn: Function) => { - const results = []; - for (let i = 0; i < items.length; i++) { - results.push(await fn(items[i], i)); - } - return results; + node_identity_file: "/tmp/identity.json", }), })); import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { generateRsaKeypair, loadIdentity, saveIdentity, registerAgent, resetAccount } from "../src/identity.js"; -import * as llm from "../src/llm.js"; -import { sleep } from "../src/utils.js"; +import { + generateRsaKeypair, + loadIdentity, + saveIdentity, + registerAgent, + resetAccount, +} from "../src/identity.js"; describe("identity", () => { beforeEach(() => vi.clearAllMocks()); @@ -64,6 +51,14 @@ describe("identity", () => { expect(id!.node_id).toBe("a1"); }); + it("migrates legacy agent_id/secret fields", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ agent_id: "a1", secret: "s1" })); + const id = loadIdentity("id.json"); + expect(id!.node_id).toBe("a1"); + expect(id!.node_secret).toBe("s1"); + }); + it("returns null when missing required fields", () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ foo: "bar" })); @@ -85,193 +80,61 @@ describe("identity", () => { }); describe("registerAgent", () => { - it("registers on first attempt when passed", async () => { + it("registers in one step and saves identity", async () => { const client = { register: vi.fn().mockResolvedValue({ - challenge_session_id: "sess", - challenges: [{ id: "c1", question: "q", option_a: "a", option_b: "b" }], - required_correct: 1, - }), - completeRegistration: vi.fn().mockResolvedValue({ - passed: true, agent_id: "new-agent", secret: "new-secret", correct_count: 1, + agent_id: "new-agent", + secret: "new-secret", + capability_rank: 0, + node_tier: "challenger", + message: "ok", }), } as any; - vi.mocked(llm.compareForRegistration).mockResolvedValue(1); - const identity = await registerAgent(client, "TestBot", vi.fn()); + const log = vi.fn(); + const identity = await registerAgent(client, "TestBot", log); + expect(identity.node_id).toBe("new-agent"); expect(identity.node_secret).toBe("new-secret"); + expect(identity.public_key_pem).toContain("BEGIN PUBLIC KEY"); + expect(identity.private_key_pem).toContain("BEGIN PRIVATE KEY"); expect(writeFileSync).toHaveBeenCalled(); + expect(client.register).toHaveBeenCalledTimes(1); + expect(client.register).toHaveBeenCalledWith(expect.stringContaining("PUBLIC KEY"), "TestBot"); }); - it("handles net > 0 (choice 0) and net < 0 (choice 1)", async () => { - const client = { - register: vi.fn().mockResolvedValue({ - challenge_session_id: "sess", - challenges: [ - { id: "c1", question: "q1", option_a: "a1", option_b: "b1" }, - { id: "c2", question: "q2", option_a: "a2", option_b: "b2" }, - ], - required_correct: 1, - }), - completeRegistration: vi.fn().mockResolvedValue({ - passed: true, agent_id: "a", secret: "s", correct_count: 2, - }), - } as any; - - // For c1: forward=1, inverse=-1 → net = 1+1 = 2 > 0 → choice 0 - // For c2: forward=-1, inverse=1 → net = -1-1 = -2 < 0 → choice 1 - vi.mocked(llm.compareForRegistration) - .mockResolvedValueOnce(1) // c1 forward - .mockResolvedValueOnce(-1) // c1 inverse - .mockResolvedValueOnce(-1) // c2 forward - .mockResolvedValueOnce(1); // c2 inverse - - const identity = await registerAgent(client, "Bot", vi.fn()); - expect(identity.node_id).toBe("a"); - }); - - it("handles challenge timeout (compareForRegistration throws)", async () => { - const client = { - register: vi.fn().mockResolvedValue({ - challenge_session_id: "sess", - challenges: [{ id: "c1", question: "q", option_a: "a", option_b: "b" }], - required_correct: 1, - }), - completeRegistration: vi.fn().mockResolvedValue({ - passed: true, agent_id: "a", secret: "s", correct_count: 1, - }), - } as any; - - // Reject during parallel phase (2 calls), then succeed during tiebreak - vi.mocked(llm.compareForRegistration) - .mockRejectedValueOnce(new Error("timeout")) - .mockRejectedValueOnce(new Error("timeout")) - .mockResolvedValue(1); // tiebreak succeeds → net becomes non-zero - const identity = await registerAgent(client, "Bot", vi.fn()); - expect(identity.node_id).toBe("a"); - }); - - it("retries when registration fails", async () => { - let attempt = 0; - const client = { - register: vi.fn().mockResolvedValue({ - challenge_session_id: "sess", - challenges: [{ id: "c1", question: "q", option_a: "a", option_b: "b" }], - required_correct: 1, - }), - completeRegistration: vi.fn().mockImplementation(async () => { - attempt++; - if (attempt < 2) return { passed: false, correct_count: 0 }; - return { passed: true, agent_id: "a", secret: "s", correct_count: 1 }; - }), - } as any; - - vi.mocked(llm.compareForRegistration).mockResolvedValue(1); - const identity = await registerAgent(client, "Bot", vi.fn()); - expect(identity.node_id).toBe("a"); - expect(client.completeRegistration).toHaveBeenCalledTimes(2); - }); - - it("retries on network error", async () => { - let attempt = 0; + it("propagates errors from the API", async () => { const client = { - register: vi.fn().mockImplementation(async () => { - attempt++; - if (attempt < 2) throw new Error("network"); - return { - challenge_session_id: "sess", - challenges: [{ id: "c1", question: "q", option_a: "a", option_b: "b" }], - required_correct: 1, - }; - }), - completeRegistration: vi.fn().mockResolvedValue({ - passed: true, agent_id: "a", secret: "s", correct_count: 1, - }), + register: vi.fn().mockRejectedValue(new Error("boom")), } as any; - vi.mocked(llm.compareForRegistration).mockResolvedValue(1); - const identity = await registerAgent(client, "Bot", vi.fn()); - expect(identity.node_id).toBe("a"); - expect(sleep).toHaveBeenCalled(); + await expect(registerAgent(client, "Bot", vi.fn())).rejects.toThrow("boom"); }); }); describe("resetAccount", () => { - it("resets on first attempt", async () => { + it("calls resetCapability and returns response", async () => { const client = { - startAccountReset: vi.fn().mockResolvedValue({ - challenge_session_id: "sess", - challenges: [{ id: "c1", question: "q", option_a: "a", option_b: "b" }], - required_correct: 1, - cooldown_minutes: 10, + resetCapability: vi.fn().mockResolvedValue({ + agent_id: "a1", + capability_rank: 0, + rank_before: 30, + challenge_locked: "250", + drop_amount: "250", }), - completeAccountReset: vi.fn().mockResolvedValue({ passed: true }), } as any; - vi.mocked(llm.compareForRegistration).mockResolvedValue(1); - await resetAccount(client, vi.fn()); - expect(client.completeAccountReset).toHaveBeenCalled(); + const result = await resetAccount(client, vi.fn()); + expect(client.resetCapability).toHaveBeenCalledTimes(1); + expect(result.rank_before).toBe(30); + expect(result.drop_amount).toBe("250"); }); - it("retries when reset fails", async () => { - let attempt = 0; + it("propagates API errors", async () => { const client = { - startAccountReset: vi.fn().mockResolvedValue({ - challenge_session_id: "sess", - challenges: [{ id: "c1", question: "q", option_a: "a", option_b: "b" }], - required_correct: 1, cooldown_minutes: 0, - }), - completeAccountReset: vi.fn().mockImplementation(async () => { - attempt++; - if (attempt < 2) return { passed: false, correct_count: 0 }; - return { passed: true }; - }), + resetCapability: vi.fn().mockRejectedValue(new Error("cooldown")), } as any; - - vi.mocked(llm.compareForRegistration).mockResolvedValue(1); - await resetAccount(client, vi.fn()); - expect(client.completeAccountReset).toHaveBeenCalledTimes(2); - }); - - it("handles cooldown error", async () => { - let attempt = 0; - const client = { - startAccountReset: vi.fn().mockImplementation(async () => { - attempt++; - if (attempt < 2) throw new Error("cooldown period"); - return { - challenge_session_id: "sess", - challenges: [{ id: "c1", question: "q", option_a: "a", option_b: "b" }], - required_correct: 1, - }; - }), - completeAccountReset: vi.fn().mockResolvedValue({ passed: true }), - } as any; - - vi.mocked(llm.compareForRegistration).mockResolvedValue(1); - await resetAccount(client, vi.fn()); - expect(sleep).toHaveBeenCalledWith(600_000); - }); - - it("handles generic error with retry", async () => { - let attempt = 0; - const client = { - startAccountReset: vi.fn().mockImplementation(async () => { - attempt++; - if (attempt < 2) throw new Error("server error"); - return { - challenge_session_id: "sess", - challenges: [{ id: "c1", question: "q", option_a: "a", option_b: "b" }], - required_correct: 1, - }; - }), - completeAccountReset: vi.fn().mockResolvedValue({ passed: true }), - } as any; - - vi.mocked(llm.compareForRegistration).mockResolvedValue(1); - await resetAccount(client, vi.fn()); - expect(sleep).toHaveBeenCalledWith(10_000); + await expect(resetAccount(client, vi.fn())).rejects.toThrow("cooldown"); }); }); }); diff --git a/tests/llm.test.ts b/tests/llm.test.ts index 0818af3..5f477c4 100644 --- a/tests/llm.test.ts +++ b/tests/llm.test.ts @@ -48,7 +48,6 @@ vi.mock("../src/config.js", () => ({ import { callLlm, - compareForRegistration, evaluateGoodEnough, comparePairwise, generateAnswer, @@ -90,34 +89,6 @@ describe("llm", () => { }); }); - describe("compareForRegistration", () => { - it("returns 1 when LLM says A", async () => { - mockResponse("After analysis, solution A is better.\nA"); - expect(await compareForRegistration("q", "a", "b")).toBe(1); - }); - - it("returns -1 when LLM says B", async () => { - mockResponse("B is clearly better\nB"); - expect(await compareForRegistration("q", "a", "b")).toBe(-1); - }); - - it("returns 0 when LLM says U", async () => { - mockResponse("Cannot determine\nU"); - expect(await compareForRegistration("q", "a", "b")).toBe(0); - }); - - it("returns 0 on LLM failure", async () => { - mockCreate.mockRejectedValue(new Error("network error")); - expect(await compareForRegistration("q", "a", "b")).toBe(0); - }); - - it("returns 0 after 2 unparseable attempts", async () => { - mockResponse("I cannot decide clearly..."); - expect(await compareForRegistration("q", "a", "b")).toBe(0); - expect(mockCreate).toHaveBeenCalledTimes(2); - }); - }); - describe("evaluateGoodEnough", () => { it("returns true for GOOD response", async () => { mockResponse("This is a genuine attempt.\nGOOD"); diff --git a/tests/main.test.ts b/tests/main.test.ts index 5e64486..781675c 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -25,6 +25,14 @@ const mockClient = { getPendingChallenges: vi.fn().mockResolvedValue({ challenges: [] }), getActiveQueries: vi.fn().mockResolvedValue({ queries: [] }), getBalance: vi.fn().mockResolvedValue({ available: "100.0" }), + getCapability: vi.fn().mockResolvedValue({ + agent_id: "agent-1", + capability_rank: 42, + node_tier: "capable", + is_dead_locked: false, + }), + getAgent: vi.fn().mockResolvedValue({ profile: {}, capability_rank: 42, node_tier: "capable" }), + getAgentStats: vi.fn().mockResolvedValue({}), }; vi.mock("../src/api-client.js", () => { @@ -34,12 +42,28 @@ vi.mock("../src/api-client.js", () => { getPendingChallenges = mockClient.getPendingChallenges; getActiveQueries = mockClient.getActiveQueries; getBalance = mockClient.getBalance; + getCapability = mockClient.getCapability; + getAgent = mockClient.getAgent; + getAgentStats = mockClient.getAgentStats; } - return { FortyTwoClient: MockFortyTwoClient }; + class MockApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } + } + return { FortyTwoClient: MockFortyTwoClient, ApiError: MockApiError }; }); +async function makeApiError(status: number, message = "err") { + const { ApiError } = await import("../src/api-client.js"); + return new ApiError(status, message); +} + vi.mock("../src/identity.js", () => ({ - loadIdentity: vi.fn().mockReturnValue({ node_id: "agent-1", secret: "sec" }), + loadIdentity: vi.fn().mockReturnValue({ node_id: "agent-1", node_secret: "sec" }), resetAccount: vi.fn().mockResolvedValue(undefined), })); @@ -59,6 +83,11 @@ vi.mock("../src/setup-logic.js", () => ({ validateModel: vi.fn().mockResolvedValue({ ok: true }), })); +vi.mock("../src/capability-challenge.js", () => ({ + createChallengeContext: vi.fn(() => ({ client: {}, inFlight: new Set() })), + processChallengeRounds: vi.fn().mockResolvedValue(0), +})); + vi.mock("../src/utils.js", () => ({ sleep: vi.fn().mockResolvedValue(undefined), secondsUntilDeadline: vi.fn().mockReturnValue(600), @@ -68,32 +97,27 @@ vi.mock("../src/utils.js", () => ({ })); import { - InsufficientFundsError, checkBalance, + fetchCapability, processChallenges, processQueries, runCycle, getTaskStats, main, } from "../src/main.js"; -import { loadIdentity, resetAccount } from "../src/identity.js"; +import { loadIdentity } from "../src/identity.js"; import { isLlmBusy } from "../src/llm.js"; import { secondsUntilDeadline, log } from "../src/utils.js"; +import { processChallengeRounds } from "../src/capability-challenge.js"; -describe("InsufficientFundsError", () => { - it("is an Error with correct name", () => { - const err = new InsufficientFundsError("low balance"); - expect(err).toBeInstanceOf(Error); - expect(err.name).toBe("InsufficientFundsError"); - expect(err.message).toBe("low balance"); - }); -}); +const CAPABLE = { agent_id: "a", capability_rank: 42, node_tier: "capable", is_dead_locked: false } as const; +const CHALLENGER = { agent_id: "a", capability_rank: 5, node_tier: "challenger", is_dead_locked: false } as const; describe("checkBalance", () => { beforeEach(() => vi.clearAllMocks()); it("returns available balance", async () => { - mockClient.getBalance.mockResolvedValue({ available: "42.5" }); + mockClient.getBalance.mockResolvedValue({ available: "42.5", challenge_locked: "10", staked: "5" }); const result = await checkBalance(mockClient as any); expect(result).toBe(42.5); }); @@ -105,6 +129,27 @@ describe("checkBalance", () => { }); }); +describe("fetchCapability", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns capability info on success", async () => { + mockClient.getCapability.mockResolvedValue(CAPABLE); + const cap = await fetchCapability(mockClient as any); + expect(cap?.node_tier).toBe("capable"); + }); + + it("returns null for old servers (404)", async () => { + mockClient.getCapability.mockRejectedValue(await makeApiError(404, "not found")); + const cap = await fetchCapability(mockClient as any); + expect(cap).toBeNull(); + }); + + it("rethrows non-404 errors", async () => { + mockClient.getCapability.mockRejectedValue(new Error("boom")); + await expect(fetchCapability(mockClient as any)).rejects.toThrow("boom"); + }); +}); + describe("getTaskStats", () => { it("returns stats object", () => { const stats = getTaskStats(); @@ -153,23 +198,6 @@ describe("processChallenges", () => { const count = await processChallenges(mockClient as any); expect(count).toBe(0); }); - - it("uses dualMode to filter by shouldAnswer", async () => { - vi.mocked(isLlmBusy).mockReturnValue(false); - vi.mocked(secondsUntilDeadline).mockReturnValue(600); - mockClient.getPendingChallenges.mockResolvedValue({ - challenges: Array.from({ length: 10 }, (_, i) => ({ - id: `ch-dual-${i}`, - has_voted: false, - query_id: `q-dual-${i}`, - effective_voting_deadline: "2099-01-01T00:00:00Z", - })), - }); - const count = await processChallenges(mockClient as any, true); - // Some are filtered by shouldAnswer hash, count should be < 10 - expect(count).toBeGreaterThanOrEqual(0); - expect(count).toBeLessThanOrEqual(10); - }); }); describe("processQueries", () => { @@ -208,59 +236,60 @@ describe("processQueries", () => { const count = await processQueries(mockClient as any); expect(count).toBe(1); }); - - it("uses dualMode to filter by shouldAnswer", async () => { - mockClient.getActiveQueries.mockResolvedValue({ - queries: Array.from({ length: 10 }, (_, i) => ({ - id: `q-dm-${i}`, - created_at: new Date(Date.now() - 60_000).toISOString(), - decision_deadline_at: new Date(Date.now() + 3600_000).toISOString(), - })), - }); - const count = await processQueries(mockClient as any, true); - expect(count).toBeGreaterThanOrEqual(0); - expect(count).toBeLessThanOrEqual(10); - }); }); describe("runCycle", () => { + const challengeCtx = { client: {} as any, inFlight: new Set() }; + beforeEach(() => { vi.clearAllMocks(); mockCfg.node_role = "JUDGE"; }); - it("processes challenges for JUDGE role", async () => { + it("processes challenges for Capable JUDGE", async () => { vi.mocked(isLlmBusy).mockReturnValue(false); mockClient.getPendingChallenges.mockResolvedValue({ challenges: [] }); - const count = await runCycle(mockClient as any); + const count = await runCycle(mockClient as any, CAPABLE, challengeCtx); expect(count).toBe(0); expect(mockClient.getPendingChallenges).toHaveBeenCalled(); - expect(mockClient.getActiveQueries).not.toHaveBeenCalled(); }); - it("processes queries for ANSWERER role", async () => { + it("processes queries for Capable ANSWERER", async () => { mockCfg.node_role = "ANSWERER"; mockClient.getActiveQueries.mockResolvedValue({ queries: [] }); - const count = await runCycle(mockClient as any); + const count = await runCycle(mockClient as any, CAPABLE, challengeCtx); expect(count).toBe(0); expect(mockClient.getActiveQueries).toHaveBeenCalled(); + }); + + it("routes Challenger to capability-challenge worker unconditionally", async () => { + vi.mocked(processChallengeRounds).mockResolvedValue(3); + const count = await runCycle(mockClient as any, CHALLENGER, challengeCtx); + expect(count).toBe(3); + expect(processChallengeRounds).toHaveBeenCalledWith(challengeCtx); expect(mockClient.getPendingChallenges).not.toHaveBeenCalled(); + expect(mockClient.getActiveQueries).not.toHaveBeenCalled(); }); - it("processes both for ANSWERER_AND_JUDGE role", async () => { - mockCfg.node_role = "ANSWERER_AND_JUDGE"; - vi.mocked(isLlmBusy).mockReturnValue(false); - mockClient.getActiveQueries.mockResolvedValue({ queries: [] }); + it("skips all work when dead-locked", async () => { + const deadLocked = { ...CHALLENGER, is_dead_locked: true }; + const count = await runCycle(mockClient as any, deadLocked, challengeCtx); + expect(count).toBe(0); + expect(processChallengeRounds).not.toHaveBeenCalled(); + expect(mockClient.getPendingChallenges).not.toHaveBeenCalled(); + }); + + it("treats null capability as Capable (backwards compat)", async () => { + mockCfg.node_role = "JUDGE"; mockClient.getPendingChallenges.mockResolvedValue({ challenges: [] }); - const count = await runCycle(mockClient as any); + const count = await runCycle(mockClient as any, null, challengeCtx); expect(count).toBe(0); - expect(mockClient.getActiveQueries).toHaveBeenCalled(); expect(mockClient.getPendingChallenges).toHaveBeenCalled(); }); it("logs warning for unknown role", async () => { mockCfg.node_role = "UNKNOWN"; - const count = await runCycle(mockClient as any); + const count = await runCycle(mockClient as any, CAPABLE, challengeCtx); expect(count).toBe(0); expect(log).toHaveBeenCalledWith(expect.stringContaining("Unknown NODE_ROLE")); }); @@ -272,7 +301,8 @@ describe("main", () => { mockCfg.node_role = "JUDGE"; mockCfg.inference_type = "openrouter"; mockCfg.openrouter_api_key = "test-key"; - vi.mocked(loadIdentity).mockReturnValue({ node_id: "agent-1", secret: "sec" }); + vi.mocked(loadIdentity).mockReturnValue({ node_id: "agent-1", node_secret: "sec" }); + mockClient.getCapability.mockResolvedValue(CAPABLE); }); it("runs one cycle then stops on abort", async () => { @@ -316,40 +346,42 @@ describe("main", () => { mockCfg.node_role = "JUDGE"; }); - it("resets account on InsufficientFundsError", async () => { + it("goes idle (warning log, no reset) when Capable has balance < min_balance", async () => { mockClient.login.mockResolvedValue({}); vi.mocked(isLlmBusy).mockReturnValue(false); + mockClient.getCapability.mockResolvedValue(CAPABLE); let callCount = 0; const ac = new AbortController(); mockClient.getBalance.mockImplementation(async () => { callCount++; - if (callCount === 1) return { available: "1.0" }; // below min_balance - ac.abort(); - return { available: "100.0" }; + if (callCount >= 2) ac.abort(); + return { available: "1.0" }; }); mockClient.getPendingChallenges.mockResolvedValue({ challenges: [] }); await main(ac.signal); - expect(resetAccount).toHaveBeenCalled(); + expect(mockClient.getPendingChallenges).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Low balance")); }); - it("handles generic error in cycle", async () => { + it("does NOT gate Challenger on min_balance — runs Capability Challenge even with 0 available", async () => { mockClient.login.mockResolvedValue({}); - mockClient.getBalance.mockResolvedValue({ available: "100.0" }); + vi.mocked(isLlmBusy).mockReturnValue(false); + mockClient.getCapability.mockResolvedValue(CHALLENGER); + vi.mocked(processChallengeRounds).mockResolvedValue(0); - const ac = new AbortController(); let callCount = 0; - mockClient.getPendingChallenges.mockImplementation(async () => { + const ac = new AbortController(); + mockClient.getBalance.mockImplementation(async () => { callCount++; - if (callCount === 1) throw new Error("random failure"); - ac.abort(); - return { challenges: [] }; + if (callCount >= 2) ac.abort(); + return { available: "0.0", challenge_locked: "250" }; }); - vi.mocked(isLlmBusy).mockReturnValue(false); await main(ac.signal); - expect(log).toHaveBeenCalledWith(expect.stringContaining("Error in polling cycle")); + expect(processChallengeRounds).toHaveBeenCalled(); + expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Low balance")); }); it("skips API key check for local inference", async () => { @@ -370,42 +402,4 @@ describe("main", () => { mockCfg.inference_type = "openrouter"; mockCfg.openrouter_api_key = "test-key"; }); - - it("sets verbose when --verbose in argv", async () => { - const origArgv = process.argv; - process.argv = [...origArgv, "--verbose"]; - mockClient.login.mockResolvedValue({}); - vi.mocked(isLlmBusy).mockReturnValue(false); - - const ac = new AbortController(); - mockClient.getBalance.mockImplementation(async () => { - ac.abort(); - return { available: "100.0" }; - }); - mockClient.getPendingChallenges.mockResolvedValue({ challenges: [] }); - - const { setVerbose } = await import("../src/utils.js"); - await main(ac.signal); - expect(setVerbose).toHaveBeenCalledWith(true); - process.argv = origArgv; - }); - - it("logs when cycle takes longer than poll_interval", async () => { - mockClient.login.mockResolvedValue({}); - vi.mocked(isLlmBusy).mockReturnValue(false); - mockCfg.poll_interval = 0; // 0 seconds - - const ac = new AbortController(); - let callCount = 0; - mockClient.getBalance.mockImplementation(async () => { - callCount++; - if (callCount >= 2) ac.abort(); - return { available: "100.0" }; - }); - mockClient.getPendingChallenges.mockResolvedValue({ challenges: [] }); - - await main(ac.signal); - expect(log).toHaveBeenCalledWith(expect.stringContaining("starting next immediately")); - mockCfg.poll_interval = 1; - }); }); From 2cdc5c023fed7bc8d4e2d22c472aa1c0b46bc816 Mon Sep 17 00:00:00 2001 From: AIvashov Date: Tue, 14 Apr 2026 21:00:38 +0700 Subject: [PATCH 02/13] Updated challenge round interactions --- src/api-client.ts | 8 ++++ src/api-types.ts | 14 +++++- src/bot.tsx | 13 +++-- src/capability-challenge.ts | 38 ++++++++------- src/cli.ts | 39 ++++++++------- src/main.ts | 34 +++++-------- tests/capability-challenge.test.ts | 76 +++++++++++++++++++++--------- tests/cli.test.ts | 28 +++++++---- tests/main.test.ts | 21 +-------- 9 files changed, 157 insertions(+), 114 deletions(-) diff --git a/src/api-client.ts b/src/api-client.ts index 37afd22..e36b395 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -5,6 +5,7 @@ import type { CapabilityHistoryEntry, ChallengeAnswer, ChallengeRound, + JoinRoundResponse, PaginatedResponse, RegistrationResponse, ResetResponse, @@ -165,6 +166,13 @@ export class FortyTwoClient { return this.request("GET", `/foundation-pool/rounds/${roundId}`); } + async joinChallengeRound(roundId: string): Promise { + return this.request( + "POST", + `/foundation-pool/rounds/${roundId}/join`, + ); + } + async submitChallengeAnswer(roundId: string, content: string): Promise { return this.request( "POST", diff --git a/src/api-types.ts b/src/api-types.ts index f7fbc54..4884215 100644 --- a/src/api-types.ts +++ b/src/api-types.ts @@ -61,18 +61,28 @@ export interface CapabilityHistoryEntry { export interface ChallengeRound { id: string; foundation_pool_id: string; - content: string; + content?: string; // hidden in listing; present after join expected_answer?: string | null; status: "active" | "settled" | "cancelled"; starts_at: string; ends_at: string; for_budget_total: string; + max_participants: number; + joined_count: number; + slots_remaining: number; + has_joined?: boolean; + has_answered?: boolean; settled_at: string | null; winners_count: number; reward_per_winner: string; created_at: string; answer_count?: number; - has_answered?: boolean; +} + +export interface JoinRoundResponse extends ChallengeRound { + content: string; // guaranteed after join + stake_amount: string; + participant_id: string; } export interface ChallengeAnswer { diff --git a/src/bot.tsx b/src/bot.tsx index 6413df2..ac88c71 100644 --- a/src/bot.tsx +++ b/src/bot.tsx @@ -4,7 +4,7 @@ import chalk from "chalk"; import { CommandInput } from "./command-input.js"; import { get as getConfig } from "./config.js"; import { COLORS } from "./constants.js"; -import { setLogFn, setVerbose, log, sleep, getPinnedTasks, formatNumber, truncateName, getRoleLabel } from "./utils.js"; +import { setLogFn, setVerbose, log, sleep, formatNumber, truncateName, getRoleLabel } from "./utils.js"; import { FortyTwoClient, ApiError } from "./api-client.js"; import { loadIdentity } from "./identity.js"; import { runCycle, checkBalance, fetchCapability, initViewerBus } from "./main.js"; @@ -182,8 +182,12 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree } pushLine(`Active challenge rounds (${page.items.length}):`); for (const r of page.items) { - const answered = r.has_answered ? " [answered]" : ""; - pushLine(` ${r.id} ends ${r.ends_at} reward ${r.reward_per_winner} FOR${answered}`); + const slots = `${r.joined_count}/${r.max_participants} joined`; + let tag = ""; + if (r.slots_remaining <= 0) tag = " [full]"; + else if (r.has_answered) tag = " [answered]"; + else if (r.has_joined) tag = " [joined]"; + pushLine(` ${r.id} ends ${r.ends_at} ${r.for_budget_total} FOR ${slots}${tag}`); } }) .catch((err) => pushLine(`✕ Error: ${err}`)); @@ -346,8 +350,7 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree // `min_balance` gates Capable nodes only. Challengers are funded // from `challenge_locked`, so a zero `available` is expected. - const isCapable = capability === null || capability.node_tier === "capable"; - if (isCapable && available < cfg.min_balance) { + if (capability.node_tier === "capable" && available < cfg.min_balance) { const msg = `Low balance: ${available.toFixed(2)} FOR < ${cfg.min_balance.toFixed(2)} required. Worker idle — run 'fortytwo reset --yes' manually.`; log(`⚠ ${msg}`); viewerBus.pushError(msg); diff --git a/src/capability-challenge.ts b/src/capability-challenge.ts index 264ee68..435f033 100644 --- a/src/capability-challenge.ts +++ b/src/capability-challenge.ts @@ -1,4 +1,4 @@ -import { FortyTwoClient, ApiError } from "./api-client.js"; +import { FortyTwoClient } from "./api-client.js"; import type { ChallengeRound } from "./api-types.js"; import * as llm from "./llm.js"; import { log, pinTask, unpinTask } from "./utils.js"; @@ -31,19 +31,11 @@ export function createChallengeContext(client: FortyTwoClient): ChallengeContext export async function processChallengeRounds(ctx: ChallengeContext): Promise { viewerBus.setState("SCANNING"); - let page; - try { - page = await ctx.client.listActiveChallengeRounds(1, 50); - } catch (err) { - // Backwards compat: old server doesn't have Foundation Pool yet. - if (err instanceof ApiError && err.status === 404) { - viewerBus.setChallengeRoundsAvailable(0); - return 0; - } - throw err; - } + const page = await ctx.client.listActiveChallengeRounds(1, 50); - const rounds = (page.items ?? []).filter((r) => !r.has_answered && r.status === "active"); + const rounds = (page.items ?? []).filter( + (r) => !r.has_answered && r.status === "active" && r.slots_remaining > 0, + ); viewerBus.setChallengeRoundsAvailable(rounds.length); if (rounds.length === 0) { @@ -94,13 +86,27 @@ async function answerChallengeRound(ctx: ChallengeContext, round: ChallengeRound pinTask(round.id, `Challenge ${tag}`); try { + // Step 1: Join the round (stakes FOR, reveals content). + let content: string; + if (round.has_joined && round.content) { + content = round.content; + } else { + viewerBus.setState("JOINING"); + log(`[challenge ${tag}] joining round...`); + const joined = await ctx.client.joinChallengeRound(round.id); + content = joined.content; + log(`[challenge ${tag}] ✓ joined (staked ${joined.stake_amount} FOR)`); + } + + // Step 2: Generate answer via LLM. viewerBus.setState("THINKING"); log(`[challenge ${tag}] answering puzzle...`); - const answer = await llm.generateAnswer(CHALLENGE_SYSTEM_PROMPT, round.content); + const answer = await llm.generateAnswer(CHALLENGE_SYSTEM_PROMPT, content); + // Step 3: Submit. viewerBus.setState("SUBMITTING"); - const response = await ctx.client.submitChallengeAnswer(round.id, answer); - log(`[challenge ${tag}] ✓ submitted (staked ${response.staked_amount} FOR)`); + await ctx.client.submitChallengeAnswer(round.id, answer); + log(`[challenge ${tag}] ✓ submitted`); } finally { unpinTask(round.id); } diff --git a/src/cli.ts b/src/cli.ts index 256f9e0..8a3a9ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -256,19 +256,14 @@ async function cmdAsk(positionals: string[]) { const { client, nodeId } = await loadClient(); // Pre-check: Challenger nodes cannot create queries. - try { - const cap = await client.getCapability(nodeId); - if (cap.node_tier !== "capable") { - console.error( - `You are still a Challenger (rank ${cap.capability_rank}/42). ` + - `Reach Capability 42 by answering challenges first.`, - ); - process.exit(1); - return; - } - } catch (err) { - // Old server (404) — fall through to createQuery which will succeed under old rules. - if (!(err instanceof ApiError && err.status === 404)) throw err; + const cap = await client.getCapability(nodeId); + if (cap.node_tier !== "capable") { + console.error( + `You are still a Challenger (rank ${cap.capability_rank}/42). ` + + `Reach Capability 42 by answering challenges first.`, + ); + process.exit(1); + return; } const encrypted = Buffer.from(question, "utf-8").toString("base64"); @@ -344,8 +339,12 @@ async function cmdChallenge(positionals: string[]) { } console.log(`Active challenge rounds (${page.items.length}):`); for (const r of page.items) { - const answered = r.has_answered ? " [answered]" : ""; - console.log(` ${r.id} ends ${r.ends_at} reward ${r.reward_per_winner} FOR${answered}`); + const slots = `${r.joined_count}/${r.max_participants} joined`; + let tag = ""; + if (r.slots_remaining <= 0) tag = " [full]"; + else if (r.has_answered) tag = " [answered]"; + else if (r.has_joined) tag = " [joined]"; + console.log(` ${r.id} ends ${r.ends_at} ${r.for_budget_total} FOR ${slots}${tag}`); } return; } @@ -358,10 +357,14 @@ async function cmdChallenge(positionals: string[]) { process.exit(1); return; } + // Auto-join if not already joined. + const round = await client.getChallengeRound(roundId); + if (!round.has_joined) { + console.log("Joining round..."); + await client.joinChallengeRound(roundId); + } const response = await client.submitChallengeAnswer(roundId, answer); - console.log( - `✓ Answer submitted — id=${response.id}, staked ${response.staked_amount} FOR`, - ); + console.log(`✓ Answer submitted — id=${response.id}`); return; } diff --git a/src/main.ts b/src/main.ts index 1748fcd..52781f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import * as config from "./config.js"; import { sleep, secondsUntilDeadline, setVerbose, log, getRoleLabel } from "./utils.js"; -import { FortyTwoClient, ApiError } from "./api-client.js"; +import { FortyTwoClient } from "./api-client.js"; import { loadIdentity } from "./identity.js"; import { judgeChallenge } from "./judging.js"; import { answerQuery } from "./answering.js"; @@ -113,22 +113,11 @@ export async function checkBalance(client: FortyTwoClient): Promise { } } -/** - * Fetch the agent's current capability state. Falls back to a "capable" view - * when the server predates TZ-001 (404 on /capability/{id}). - */ -export async function fetchCapability(client: FortyTwoClient): Promise { - try { - const cap = await client.getCapability(); - viewerBus.setCapability(cap.capability_rank, cap.node_tier, cap.is_dead_locked); - return cap; - } catch (err) { - if (err instanceof ApiError && err.status === 404) { - // Old server — treat as capable with unknown rank. - return null; - } - throw err; - } +/** Fetch the agent's current capability state. */ +export async function fetchCapability(client: FortyTwoClient): Promise { + const cap = await client.getCapability(); + viewerBus.setCapability(cap.capability_rank, cap.node_tier, cap.is_dead_locked); + return cap; } // Track in-flight task IDs to avoid duplicates across cycles @@ -252,13 +241,13 @@ export async function processQueries(client: FortyTwoClient, dualMode = false): */ export async function runCycle( client: FortyTwoClient, - capability: CapabilityInfo | null, + capability: CapabilityInfo, challengeCtx: ChallengeContext, ): Promise { const cfg = config.get(); const role = cfg.node_role; - if (capability?.is_dead_locked) { + if (capability.is_dead_locked) { log("Dead lock — no FOR available. Run 'fortytwo reset' to unlock."); viewerBus.pushError("Dead lock: no FOR available. Reset required."); return 0; @@ -267,11 +256,11 @@ export async function runCycle( // Challenger: participate in Capability Challenge rounds. ELO is frozen, so // we do not run the normal answer/judge loops for Challengers — reaching // rank 42 requires solving puzzles. - if (capability?.node_tier === "challenger") { + if (capability.node_tier === "challenger") { return await processChallengeRounds(challengeCtx); } - // Capable (or old server without capability endpoint): normal flow. + // Capable: normal flow. let total = 0; if (role === "JUDGE") { total += await processChallenges(client, false); @@ -348,8 +337,7 @@ export async function main(signal?: AbortSignal): Promise { // on queries/judgments). Challengers are funded from `challenge_locked` // instead, so a zero `available` balance is expected and must not block // their Capability Challenge participation. - const isCapable = capability === null || capability.node_tier === "capable"; - if (isCapable && available < cfg.min_balance) { + if (capability.node_tier === "capable" && available < cfg.min_balance) { const msg = `Low balance: ${available.toFixed(2)} FOR < ${cfg.min_balance.toFixed(2)} required. Worker idle — run 'fortytwo reset --yes' manually.`; log(msg); viewerBus.pushError(msg); diff --git a/tests/capability-challenge.test.ts b/tests/capability-challenge.test.ts index 5657153..c418374 100644 --- a/tests/capability-challenge.test.ts +++ b/tests/capability-challenge.test.ts @@ -38,7 +38,6 @@ import { processChallengeRounds, createChallengeContext, } from "../src/capability-challenge.js"; -import { ApiError } from "../src/api-client.js"; // In 2026-04-13 the test is pinned to a future ends_at const FAR_FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString(); @@ -48,17 +47,21 @@ function buildRound(overrides: Record = {}) { return { id: "round-aaaaaaaa", foundation_pool_id: "fp-1", - content: "Is sky blue?", + // content is hidden in listing; revealed after join. status: "active", starts_at: new Date(Date.now() - 1000).toISOString(), ends_at: FAR_FUTURE, for_budget_total: "100", + max_participants: 20, + joined_count: 5, + slots_remaining: 15, + has_joined: false, + has_answered: false, settled_at: null, winners_count: 0, reward_per_winner: "10", created_at: new Date(Date.now() - 1000).toISOString(), answer_count: 0, - has_answered: false, ...overrides, }; } @@ -68,6 +71,12 @@ function makeClient(partial: Record = {}) { listActiveChallengeRounds: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, }), + joinChallengeRound: vi.fn().mockResolvedValue({ + id: "round-aaaaaaaa", + content: "Is sky blue?", + stake_amount: "10", + participant_id: "p-1", + }), submitChallengeAnswer: vi.fn().mockResolvedValue({ id: "ans-1", round_id: "round-aaaaaaaa", @@ -169,8 +178,8 @@ describe("processChallengeRounds", () => { expect(ctx.inFlight.size).toBe(0); }); - it("generates an answer and submits it on the happy path", async () => { - const round = buildRound(); + it("joins round, gets content, generates answer, and submits (new server)", async () => { + const round = buildRound(); // content hidden const client = makeClient({ listActiveChallengeRounds: vi.fn().mockResolvedValue({ items: [round], total: 1, page: 1, page_size: 20, @@ -181,17 +190,47 @@ describe("processChallengeRounds", () => { const count = await processChallengeRounds(ctx); expect(count).toBe(1); + expect(client.joinChallengeRound).toHaveBeenCalledWith(round.id); expect(mockLlm.generateAnswer).toHaveBeenCalledTimes(1); expect(mockLlm.generateAnswer).toHaveBeenCalledWith( expect.stringContaining("logic puzzle"), - round.content, + "Is sky blue?", // from joinChallengeRound mock ); expect(client.submitChallengeAnswer).toHaveBeenCalledWith(round.id, "Yes"); - expect(mockUtils.pinTask).toHaveBeenCalledWith(round.id, expect.any(String)); - expect(mockUtils.unpinTask).toHaveBeenCalledWith(round.id); expect(ctx.inFlight.size).toBe(0); }); + it("uses cached content when already joined", async () => { + const round = buildRound({ has_joined: true, content: "Cached question" }); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [round], total: 1, page: 1, page_size: 20, + }), + }); + const ctx = createChallengeContext(client); + + await processChallengeRounds(ctx); + + expect(client.joinChallengeRound).not.toHaveBeenCalled(); + expect(mockLlm.generateAnswer).toHaveBeenCalledWith( + expect.any(String), + "Cached question", + ); + }); + + it("filters out full rounds (slots_remaining = 0)", async () => { + const round = buildRound({ slots_remaining: 0 }); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [round], total: 1, page: 1, page_size: 20, + }), + }); + const ctx = createChallengeContext(client); + const count = await processChallengeRounds(ctx); + expect(count).toBe(0); + expect(client.joinChallengeRound).not.toHaveBeenCalled(); + }); + it("keeps processing subsequent rounds after a submit error", async () => { const goodRound = buildRound({ id: "good-round" }); const badRound = buildRound({ id: "bad-round" }); @@ -199,6 +238,9 @@ describe("processChallengeRounds", () => { listActiveChallengeRounds: vi.fn().mockResolvedValue({ items: [badRound, goodRound], total: 2, page: 1, page_size: 20, }), + joinChallengeRound: vi.fn().mockResolvedValue({ + content: "Q?", stake_amount: "10", participant_id: "p", + }), submitChallengeAnswer: vi.fn() .mockRejectedValueOnce(new Error("boom")) .mockResolvedValueOnce({ id: "ans-good", staked_amount: "10" }), @@ -213,17 +255,6 @@ describe("processChallengeRounds", () => { expect(mockUtils.log).toHaveBeenCalledWith(expect.stringContaining("failed")); }); - it("returns 0 gracefully on 404 (old server without Foundation Pool)", async () => { - const client = makeClient({ - listActiveChallengeRounds: vi.fn().mockRejectedValue(new ApiError(404, "Not found")), - }); - const ctx = createChallengeContext(client); - const count = await processChallengeRounds(ctx); - expect(count).toBe(0); - expect(mockBus.setChallengeRoundsAvailable).toHaveBeenCalledWith(0); - expect(mockLlm.generateAnswer).not.toHaveBeenCalled(); - }); - it("stops processing further rounds once the node transitions to Capable mid-cycle", async () => { const r1 = buildRound({ id: "r1" }); const r2 = buildRound({ id: "r2" }); @@ -232,6 +263,9 @@ describe("processChallengeRounds", () => { listActiveChallengeRounds: vi.fn().mockResolvedValue({ items: [r1, r2, r3], total: 3, page: 1, page_size: 20, }), + joinChallengeRound: vi.fn().mockResolvedValue({ + content: "Q?", stake_amount: "10", participant_id: "p", + }), submitChallengeAnswer: vi.fn() .mockResolvedValueOnce({ id: "ans-1", staked_amount: "10" }) // r1 succeeds .mockRejectedValueOnce(new Error("Capable nodes cannot participate in Capability Challenge rounds")) // r2 tier mismatch @@ -250,9 +284,9 @@ describe("processChallengeRounds", () => { ); }); - it("rethrows non-404 listActiveChallengeRounds errors", async () => { + it("propagates listActiveChallengeRounds errors", async () => { const client = makeClient({ - listActiveChallengeRounds: vi.fn().mockRejectedValue(new ApiError(500, "boom")), + listActiveChallengeRounds: vi.fn().mockRejectedValue(new Error("boom")), }); const ctx = createChallengeContext(client); await expect(processChallengeRounds(ctx)).rejects.toThrow("boom"); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 8672a85..6b7c67d 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -43,6 +43,10 @@ const mockClient = { listActiveChallengeRounds: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, }), + getChallengeRound: vi.fn().mockResolvedValue({ has_joined: false }), + joinChallengeRound: vi.fn().mockResolvedValue({ + content: "Q?", stake_amount: "10", participant_id: "p-1", + }), submitChallengeAnswer: vi.fn().mockResolvedValue({ id: "ans-1", staked_amount: "10", }), @@ -60,6 +64,8 @@ vi.mock("../src/api-client.js", () => { getCapability = mockClient.getCapability; resetCapability = mockClient.resetCapability; listActiveChallengeRounds = mockClient.listActiveChallengeRounds; + getChallengeRound = mockClient.getChallengeRound; + joinChallengeRound = mockClient.joinChallengeRound; submitChallengeAnswer = mockClient.submitChallengeAnswer; getCapabilityHistory = mockClient.getCapabilityHistory; } @@ -397,13 +403,6 @@ describe("cli", () => { expect(errOut).toContain("Challenger"); }); - it("falls back gracefully on 404 from getCapability (old server)", async () => { - const { ApiError } = await import("../src/api-client.js"); - mockClient.getCapability.mockRejectedValue(new ApiError(404, "not found")); - await runCli(["ask", "What"]); - expect(mockClient.createQuery).toHaveBeenCalled(); - }); - it("surfaces 403 from createQuery as friendly message", async () => { mockClient.getCapability.mockResolvedValue({ agent_id: "agent-1", @@ -513,7 +512,7 @@ describe("cli", () => { await runCli(["challenge", "list"]); const out = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); expect(out).toContain("round-1"); - expect(out).toContain("10 FOR"); + expect(out).toContain("100 FOR"); }); it("list says so when no rounds", async () => { @@ -525,13 +524,24 @@ describe("cli", () => { expect(out).toContain("No active challenge rounds"); }); - it("answer submits an answer", async () => { + it("auto-joins then submits an answer", async () => { + mockClient.getChallengeRound.mockResolvedValue({ has_joined: false }); + mockClient.joinChallengeRound.mockResolvedValue({ content: "Q?", stake_amount: "10" }); mockClient.submitChallengeAnswer.mockResolvedValue({ id: "ans-1", staked_amount: "10", round_id: "r1", agent_id: "a", content: "Yes", is_correct: null, capability_delta: 0, reward_amount: "0", submitted_at: "", validated_at: null, }); await runCli(["challenge", "answer", "r1", "Yes"]); + expect(mockClient.joinChallengeRound).toHaveBeenCalledWith("r1"); + expect(mockClient.submitChallengeAnswer).toHaveBeenCalledWith("r1", "Yes"); + }); + + it("skips join when already joined", async () => { + mockClient.getChallengeRound.mockResolvedValue({ has_joined: true }); + mockClient.submitChallengeAnswer.mockResolvedValue({ id: "ans-1" }); + await runCli(["challenge", "answer", "r1", "Yes"]); + expect(mockClient.joinChallengeRound).not.toHaveBeenCalled(); expect(mockClient.submitChallengeAnswer).toHaveBeenCalledWith("r1", "Yes"); }); diff --git a/tests/main.test.ts b/tests/main.test.ts index 781675c..01e4da8 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -57,11 +57,6 @@ vi.mock("../src/api-client.js", () => { return { FortyTwoClient: MockFortyTwoClient, ApiError: MockApiError }; }); -async function makeApiError(status: number, message = "err") { - const { ApiError } = await import("../src/api-client.js"); - return new ApiError(status, message); -} - vi.mock("../src/identity.js", () => ({ loadIdentity: vi.fn().mockReturnValue({ node_id: "agent-1", node_secret: "sec" }), resetAccount: vi.fn().mockResolvedValue(undefined), @@ -138,13 +133,7 @@ describe("fetchCapability", () => { expect(cap?.node_tier).toBe("capable"); }); - it("returns null for old servers (404)", async () => { - mockClient.getCapability.mockRejectedValue(await makeApiError(404, "not found")); - const cap = await fetchCapability(mockClient as any); - expect(cap).toBeNull(); - }); - - it("rethrows non-404 errors", async () => { + it("propagates errors", async () => { mockClient.getCapability.mockRejectedValue(new Error("boom")); await expect(fetchCapability(mockClient as any)).rejects.toThrow("boom"); }); @@ -279,14 +268,6 @@ describe("runCycle", () => { expect(mockClient.getPendingChallenges).not.toHaveBeenCalled(); }); - it("treats null capability as Capable (backwards compat)", async () => { - mockCfg.node_role = "JUDGE"; - mockClient.getPendingChallenges.mockResolvedValue({ challenges: [] }); - const count = await runCycle(mockClient as any, null, challengeCtx); - expect(count).toBe(0); - expect(mockClient.getPendingChallenges).toHaveBeenCalled(); - }); - it("logs warning for unknown role", async () => { mockCfg.node_role = "UNKNOWN"; const count = await runCycle(mockClient as any, CAPABLE, challengeCtx); From 3eb5b3c446c9e50c5d576dfe5d4ff7b078e8a6a5 Mon Sep 17 00:00:00 2001 From: AIvashov Date: Wed, 15 Apr 2026 16:26:50 +0700 Subject: [PATCH 03/13] fix: prevent FOR burn on batch failures --- src/capability-challenge.ts | 107 +++++++++++++++++++++++------ tests/capability-challenge.test.ts | 102 ++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 24 deletions(-) diff --git a/src/capability-challenge.ts b/src/capability-challenge.ts index 435f033..a814b5c 100644 --- a/src/capability-challenge.ts +++ b/src/capability-challenge.ts @@ -13,6 +13,19 @@ const CHALLENGE_SYSTEM_PROMPT = [ // Skip rounds whose deadline is closer than this — not enough time to generate + submit. const MIN_TIME_LEFT_MS = 30_000; +/** + * Raised when `llm.generateAnswer` fails after a successful join. Signals that + * inference is unavailable, so the processing loop must stop — otherwise we + * keep staking FOR on rounds we can't answer until `challenge_locked` is + * drained into dead-lock. + */ +class LlmFailureError extends Error { + constructor(cause: Error) { + super(`LLM generation failed: ${cause.message}`); + this.name = "LlmFailureError"; + } +} + export interface ChallengeContext { client: FortyTwoClient; inFlight: Set; @@ -56,11 +69,12 @@ export async function processChallengeRounds(ctx: ChallengeContext): Promise { @@ -86,22 +113,22 @@ async function answerChallengeRound(ctx: ChallengeContext, round: ChallengeRound pinTask(round.id, `Challenge ${tag}`); try { - // Step 1: Join the round (stakes FOR, reveals content). - let content: string; - if (round.has_joined && round.content) { - content = round.content; - } else { - viewerBus.setState("JOINING"); - log(`[challenge ${tag}] joining round...`); - const joined = await ctx.client.joinChallengeRound(round.id); - content = joined.content; - log(`[challenge ${tag}] ✓ joined (staked ${joined.stake_amount} FOR)`); - } + // Step 1: Obtain puzzle content. Listing doesn't carry `content`; detail + // does (for users who have already joined) and `joinChallengeRound` + // returns it directly for fresh joins. + const content = await obtainContent(ctx, round, tag); - // Step 2: Generate answer via LLM. + // Step 2: Generate answer via LLM. Wrap in a dedicated error type so the + // batch loop can detect and abort — we must not keep joining rounds when + // inference is dead. viewerBus.setState("THINKING"); log(`[challenge ${tag}] answering puzzle...`); - const answer = await llm.generateAnswer(CHALLENGE_SYSTEM_PROMPT, content); + let answer: string; + try { + answer = await llm.generateAnswer(CHALLENGE_SYSTEM_PROMPT, content); + } catch (err) { + throw new LlmFailureError(err as Error); + } // Step 3: Submit. viewerBus.setState("SUBMITTING"); @@ -111,3 +138,41 @@ async function answerChallengeRound(ctx: ChallengeContext, round: ChallengeRound unpinTask(round.id); } } + +async function obtainContent( + ctx: ChallengeContext, + round: ChallengeRound, + tag: string, +): Promise { + // Already joined in a previous cycle — no need to stake again, just fetch + // the round detail (server returns `content` for participants). + if (round.has_joined) { + log(`[challenge ${tag}] already joined — fetching content...`); + const detail = await ctx.client.getChallengeRound(round.id); + if (!detail.content) { + throw new Error(`Round detail is missing content despite has_joined=true`); + } + return detail.content; + } + + viewerBus.setState("JOINING"); + log(`[challenge ${tag}] joining round...`); + try { + const joined = await ctx.client.joinChallengeRound(round.id); + log(`[challenge ${tag}] ✓ joined (staked ${joined.stake_amount} FOR)`); + return joined.content; + } catch (err) { + const msg = (err as Error).message ?? String(err); + // Race: listing said has_joined=false, server says we already joined. + // Fetch detail instead of double-staking. + if (/already joined/i.test(msg)) { + log(`[challenge ${tag}] join race — fetching content from detail...`); + const detail = await ctx.client.getChallengeRound(round.id); + if (!detail.content) { + throw new Error(`Round detail is missing content after already-joined fallback`); + } + return detail.content; + } + throw err; + } +} diff --git a/tests/capability-challenge.test.ts b/tests/capability-challenge.test.ts index c418374..70127e3 100644 --- a/tests/capability-challenge.test.ts +++ b/tests/capability-challenge.test.ts @@ -71,6 +71,10 @@ function makeClient(partial: Record = {}) { listActiveChallengeRounds: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, }), + getChallengeRound: vi.fn().mockResolvedValue({ + id: "round-aaaaaaaa", + content: "Detail question?", + }), joinChallengeRound: vi.fn().mockResolvedValue({ id: "round-aaaaaaaa", content: "Is sky blue?", @@ -200,21 +204,94 @@ describe("processChallengeRounds", () => { expect(ctx.inFlight.size).toBe(0); }); - it("uses cached content when already joined", async () => { - const round = buildRound({ has_joined: true, content: "Cached question" }); + it("fetches content via getChallengeRound when already joined (no re-stake)", async () => { + const round = buildRound({ has_joined: true }); const client = makeClient({ listActiveChallengeRounds: vi.fn().mockResolvedValue({ items: [round], total: 1, page: 1, page_size: 20, }), + getChallengeRound: vi.fn().mockResolvedValue({ + id: round.id, content: "Resumed question", + }), }); const ctx = createChallengeContext(client); await processChallengeRounds(ctx); expect(client.joinChallengeRound).not.toHaveBeenCalled(); + expect(client.getChallengeRound).toHaveBeenCalledWith(round.id); + expect(mockLlm.generateAnswer).toHaveBeenCalledWith( + expect.any(String), + "Resumed question", + ); + expect(client.submitChallengeAnswer).toHaveBeenCalled(); + }); + + it("falls back to detail on 'Already joined' race", async () => { + const round = buildRound({ has_joined: false }); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [round], total: 1, page: 1, page_size: 20, + }), + joinChallengeRound: vi.fn().mockRejectedValue(new Error("Already joined this round")), + getChallengeRound: vi.fn().mockResolvedValue({ id: round.id, content: "Race content" }), + }); + const ctx = createChallengeContext(client); + + await processChallengeRounds(ctx); + + expect(client.joinChallengeRound).toHaveBeenCalledTimes(1); + expect(client.getChallengeRound).toHaveBeenCalledWith(round.id); expect(mockLlm.generateAnswer).toHaveBeenCalledWith( expect.any(String), - "Cached question", + "Race content", + ); + expect(client.submitChallengeAnswer).toHaveBeenCalled(); + }); + + it("breaks the loop on Insufficient FOR balance (stops wasting requests)", async () => { + const r1 = buildRound({ id: "r1" }); + const r2 = buildRound({ id: "r2" }); + const r3 = buildRound({ id: "r3" }); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [r1, r2, r3], total: 3, page: 1, page_size: 20, + }), + joinChallengeRound: vi.fn() + .mockResolvedValueOnce({ content: "Q1", stake_amount: "10" }) + .mockRejectedValueOnce(new Error("Insufficient FOR balance: need 10, have 0")), + }); + const ctx = createChallengeContext(client); + + await processChallengeRounds(ctx); + + // r1 joined+answered; r2 join failed → break; r3 untouched. + expect(client.joinChallengeRound).toHaveBeenCalledTimes(2); + expect(client.submitChallengeAnswer).toHaveBeenCalledTimes(1); + expect(mockUtils.log).toHaveBeenCalledWith( + expect.stringContaining("challenge_locked FOR exhausted"), + ); + }); + + it("breaks the loop on LLM failure (prevents staking on unanswerable rounds)", async () => { + const r1 = buildRound({ id: "r1" }); + const r2 = buildRound({ id: "r2" }); + const r3 = buildRound({ id: "r3" }); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [r1, r2, r3], total: 3, page: 1, page_size: 20, + }), + }); + mockLlm.generateAnswer.mockRejectedValueOnce(new Error("Connection refused — local inference down")); + const ctx = createChallengeContext(client); + + await processChallengeRounds(ctx); + + // r1 joined, LLM failed → break. r2/r3 must NOT be joined (FOR not burned). + expect(client.joinChallengeRound).toHaveBeenCalledTimes(1); + expect(client.submitChallengeAnswer).not.toHaveBeenCalled(); + expect(mockUtils.log).toHaveBeenCalledWith( + expect.stringContaining("Inference unavailable"), ); }); @@ -284,6 +361,25 @@ describe("processChallengeRounds", () => { ); }); + it("breaks on tier-mismatch error message from join", async () => { + const r1 = buildRound({ id: "r1" }); + const r2 = buildRound({ id: "r2" }); + const client = makeClient({ + listActiveChallengeRounds: vi.fn().mockResolvedValue({ + items: [r1, r2], total: 2, page: 1, page_size: 20, + }), + joinChallengeRound: vi.fn().mockRejectedValue( + new Error("Capable nodes cannot participate in Capability Challenge rounds"), + ), + }); + const ctx = createChallengeContext(client); + + await processChallengeRounds(ctx); + + expect(client.joinChallengeRound).toHaveBeenCalledTimes(1); + expect(client.submitChallengeAnswer).not.toHaveBeenCalled(); + }); + it("propagates listActiveChallengeRounds errors", async () => { const client = makeClient({ listActiveChallengeRounds: vi.fn().mockRejectedValue(new Error("boom")), From 2314fa636dcfc1e81436618fa81e579c441a22c7 Mon Sep 17 00:00:00 2001 From: AIvashov Date: Wed, 15 Apr 2026 17:18:45 +0700 Subject: [PATCH 04/13] fixed double flags --- src/cli.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 8a3a9ed..c1ff044 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -518,12 +518,11 @@ Usage: fortytwo version Show version Setup flags: - --node-name NAME Node local name + --node-name NAME Local name for the node profile (e.g. "my-judge") --inference-type TYPE openrouter | self-hosted --openrouter-api-key KEY OpenRouter API key --model-name NAME Model name --self-hosted-api-base URL Local inference URL - --node-name NAME Local name for the node profile (e.g. "my-judge") --node-role ROLE JUDGE | ANSWERER | ANSWERER_AND_JUDGE --skip-validation Skip model validation From b9e52b0906a3c266fa89680484021d17e5d5a97b Mon Sep 17 00:00:00 2001 From: AIvashov Date: Wed, 15 Apr 2026 19:42:24 +0700 Subject: [PATCH 05/13] added ping function --- src/bot.tsx | 63 ++++++++++++++++++++---------- src/capability-challenge.ts | 8 +++- src/llm.ts | 30 ++++++++++++++ src/main.ts | 60 +++++++++++++++++++--------- tests/capability-challenge.test.ts | 12 +++--- tests/main.test.ts | 58 ++++++++++++++++++++++++--- 6 files changed, 178 insertions(+), 53 deletions(-) diff --git a/src/bot.tsx b/src/bot.tsx index ac88c71..20a9074 100644 --- a/src/bot.tsx +++ b/src/bot.tsx @@ -8,7 +8,8 @@ import { setLogFn, setVerbose, log, sleep, formatNumber, truncateName, getRoleLa import { FortyTwoClient, ApiError } from "./api-client.js"; import { loadIdentity } from "./identity.js"; import { runCycle, checkBalance, fetchCapability, initViewerBus } from "./main.js"; -import { createChallengeContext } from "./capability-challenge.js"; +import { createChallengeContext, LlmFailureError } from "./capability-challenge.js"; +import { pingLlm } from "./llm.js"; import { getLlmStats } from "./llm.js"; import { executeCommand, SUGGESTIONS } from "./commands.js"; import { validateConfig, validateModel } from "./setup-logic.js"; @@ -336,35 +337,55 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree const challengeCtx = createChallengeContext(c); let cycles = 0; + // Inference-down guard: skip cycles until a cheap ping succeeds. + let inferenceDown = false; while (!cancelled) { const cycleStart = Date.now(); try { - const available = await checkBalance(c); - if (!cancelled) setBalance(available); - const capability = await fetchCapability(c); - if (!cancelled && capability) { - setCapabilityRank(capability.capability_rank); - setNodeTier(capability.node_tier); - setDeadLocked(capability.is_dead_locked); + if (inferenceDown) { + log("Inference was down — probing with ping..."); + if (await pingLlm()) { + log("✓ Inference restored, resuming work"); + inferenceDown = false; + } else { + log("✕ Inference still unavailable — skipping cycle"); + } } - // `min_balance` gates Capable nodes only. Challengers are funded - // from `challenge_locked`, so a zero `available` is expected. - if (capability.node_tier === "capable" && available < cfg.min_balance) { - const msg = `Low balance: ${available.toFixed(2)} FOR < ${cfg.min_balance.toFixed(2)} required. Worker idle — run 'fortytwo reset --yes' manually.`; - log(`⚠ ${msg}`); - viewerBus.pushError(msg); - } else { - const count = await runCycle(c, capability, challengeCtx); - cycles++; - viewerBus.updateStats({ cycles }); - if (count > 0) log(`✓ Processed ${count} items this cycle`); + if (!inferenceDown) { + const available = await checkBalance(c); + if (!cancelled) setBalance(available); + const capability = await fetchCapability(c); + if (!cancelled && capability) { + setCapabilityRank(capability.capability_rank); + setNodeTier(capability.node_tier); + setDeadLocked(capability.is_dead_locked); + } + + // `min_balance` gates Capable nodes only. Challengers are funded + // from `challenge_locked`, so a zero `available` is expected. + if (capability.node_tier === "capable" && available < cfg.min_balance) { + const msg = `Low balance: ${available.toFixed(2)} FOR < ${cfg.min_balance.toFixed(2)} required. Worker idle — run 'fortytwo reset --yes' manually.`; + log(`⚠ ${msg}`); + viewerBus.pushError(msg); + } else { + const count = await runCycle(c, capability, challengeCtx); + cycles++; + viewerBus.updateStats({ cycles }); + if (count > 0) log(`✓ Processed ${count} items this cycle`); + } } } catch (err) { if (cancelled) return; const errMsg = (err as Error).message ?? String(err); - log(`✕ Error in cycle: ${err}`); - viewerBus.pushError(errMsg); + if (err instanceof LlmFailureError) { + inferenceDown = true; + log(`⚠ Inference unavailable — pausing until ping succeeds. (${errMsg})`); + viewerBus.pushError(`Inference unavailable: ${errMsg}`); + } else { + log(`✕ Error in cycle: ${err}`); + viewerBus.pushError(errMsg); + } } if (cancelled) return; diff --git a/src/capability-challenge.ts b/src/capability-challenge.ts index a814b5c..5d16056 100644 --- a/src/capability-challenge.ts +++ b/src/capability-challenge.ts @@ -17,9 +17,10 @@ const MIN_TIME_LEFT_MS = 30_000; * Raised when `llm.generateAnswer` fails after a successful join. Signals that * inference is unavailable, so the processing loop must stop — otherwise we * keep staking FOR on rounds we can't answer until `challenge_locked` is - * drained into dead-lock. + * drained into dead-lock. Rethrown from `processChallengeRounds` so the main + * polling loop can gate subsequent cycles on a `pingLlm()` health check. */ -class LlmFailureError extends Error { +export class LlmFailureError extends Error { constructor(cause: Error) { super(`LLM generation failed: ${cause.message}`); this.name = "LlmFailureError"; @@ -75,6 +76,9 @@ export async function processChallengeRounds(ctx: ChallengeContext): Promise { + const cfg = config.get(); + const isLocal = cfg.inference_type === "self-hosted"; + if (!isLocal && !cfg.openrouter_api_key) return false; + + try { + const client = getClient(); + await client.chat.completions.create( + { + model: cfg.model_name, + messages: [{ role: "user", content: "ok" }], + temperature: 0, + max_tokens: 1, + }, + { signal: AbortSignal.timeout(timeoutMs), maxRetries: 0 }, + ); + return true; + } catch (err) { + verbose(`pingLlm failed: ${err}`); + return false; + } +} + export async function evaluateGoodEnough( problem: string, solution: string, diff --git a/src/main.ts b/src/main.ts index 52781f9..41c6bc4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,12 +5,13 @@ import { FortyTwoClient } from "./api-client.js"; import { loadIdentity } from "./identity.js"; import { judgeChallenge } from "./judging.js"; import { answerQuery } from "./answering.js"; -import { isLlmBusy } from "./llm.js"; +import { isLlmBusy, pingLlm } from "./llm.js"; import { validateModel } from "./setup-logic.js"; import { viewerBus, type VisibleQuery } from "./event-bus.js"; import { createChallengeContext, processChallengeRounds, + LlmFailureError, type ChallengeContext, } from "./capability-challenge.js"; import type { CapabilityInfo } from "./api-types.js"; @@ -327,31 +328,52 @@ export async function main(signal?: AbortSignal): Promise { log(`✓ Starting polling loop (interval: ${cfg.poll_interval}s)`); let cycles = 0; + // When the LLM fails mid-cycle we pause the worker until a cheap ping + // succeeds — otherwise we would keep staking FOR on rounds we can't answer. + let inferenceDown = false; while (!signal?.aborted) { const cycleStart = Date.now(); try { - const available = await checkBalance(client); - const capability = await fetchCapability(client); - - // `min_balance` gates Capable nodes (they spend `available` FOR to stake - // on queries/judgments). Challengers are funded from `challenge_locked` - // instead, so a zero `available` balance is expected and must not block - // their Capability Challenge participation. - if (capability.node_tier === "capable" && available < cfg.min_balance) { - const msg = `Low balance: ${available.toFixed(2)} FOR < ${cfg.min_balance.toFixed(2)} required. Worker idle — run 'fortytwo reset --yes' manually.`; - log(msg); - viewerBus.pushError(msg); - } else { - const count = await runCycle(client, capability, challengeCtx); - cycles++; - viewerBus.updateStats({ cycles }); - if (count > 0) log(`Processed ${count} items this cycle`); + if (inferenceDown) { + log("Inference was down — probing with ping..."); + if (await pingLlm()) { + log("✓ Inference restored, resuming work"); + inferenceDown = false; + } else { + log("✕ Inference still unavailable — skipping cycle"); + } + } + + if (!inferenceDown) { + const available = await checkBalance(client); + const capability = await fetchCapability(client); + + // `min_balance` gates Capable nodes (they spend `available` FOR to stake + // on queries/judgments). Challengers are funded from `challenge_locked` + // instead, so a zero `available` balance is expected and must not block + // their Capability Challenge participation. + if (capability.node_tier === "capable" && available < cfg.min_balance) { + const msg = `Low balance: ${available.toFixed(2)} FOR < ${cfg.min_balance.toFixed(2)} required. Worker idle — run 'fortytwo reset --yes' manually.`; + log(msg); + viewerBus.pushError(msg); + } else { + const count = await runCycle(client, capability, challengeCtx); + cycles++; + viewerBus.updateStats({ cycles }); + if (count > 0) log(`Processed ${count} items this cycle`); + } } } catch (err) { if (signal?.aborted) return; const errMsg = (err as Error).message ?? String(err); - log(`Error in polling cycle: ${errMsg}`); - viewerBus.pushError(errMsg); + if (err instanceof LlmFailureError) { + inferenceDown = true; + log(`Inference unavailable — pausing worker until ping succeeds. (${errMsg})`); + viewerBus.pushError(`Inference unavailable: ${errMsg}`); + } else { + log(`Error in polling cycle: ${errMsg}`); + viewerBus.pushError(errMsg); + } } if (signal?.aborted) return; diff --git a/tests/capability-challenge.test.ts b/tests/capability-challenge.test.ts index 70127e3..7d88f66 100644 --- a/tests/capability-challenge.test.ts +++ b/tests/capability-challenge.test.ts @@ -273,7 +273,7 @@ describe("processChallengeRounds", () => { ); }); - it("breaks the loop on LLM failure (prevents staking on unanswerable rounds)", async () => { + it("rethrows LlmFailureError so the polling loop can ping-gate the next cycle", async () => { const r1 = buildRound({ id: "r1" }); const r2 = buildRound({ id: "r2" }); const r3 = buildRound({ id: "r3" }); @@ -285,14 +285,14 @@ describe("processChallengeRounds", () => { mockLlm.generateAnswer.mockRejectedValueOnce(new Error("Connection refused — local inference down")); const ctx = createChallengeContext(client); - await processChallengeRounds(ctx); + await expect(processChallengeRounds(ctx)).rejects.toMatchObject({ + name: "LlmFailureError", + }); - // r1 joined, LLM failed → break. r2/r3 must NOT be joined (FOR not burned). + // r1 joined, LLM failed → throw. r2/r3 must NOT be joined (FOR not burned). expect(client.joinChallengeRound).toHaveBeenCalledTimes(1); expect(client.submitChallengeAnswer).not.toHaveBeenCalled(); - expect(mockUtils.log).toHaveBeenCalledWith( - expect.stringContaining("Inference unavailable"), - ); + expect(ctx.inFlight.size).toBe(0); }); it("filters out full rounds (slots_remaining = 0)", async () => { diff --git a/tests/main.test.ts b/tests/main.test.ts index 01e4da8..5492037 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -72,16 +72,26 @@ vi.mock("../src/answering.js", () => ({ vi.mock("../src/llm.js", () => ({ isLlmBusy: vi.fn().mockReturnValue(false), + pingLlm: vi.fn().mockResolvedValue(true), })); vi.mock("../src/setup-logic.js", () => ({ validateModel: vi.fn().mockResolvedValue({ ok: true }), })); -vi.mock("../src/capability-challenge.js", () => ({ - createChallengeContext: vi.fn(() => ({ client: {}, inFlight: new Set() })), - processChallengeRounds: vi.fn().mockResolvedValue(0), -})); +vi.mock("../src/capability-challenge.js", () => { + class LlmFailureError extends Error { + constructor(cause: Error) { + super(`LLM generation failed: ${cause.message}`); + this.name = "LlmFailureError"; + } + } + return { + createChallengeContext: vi.fn(() => ({ client: {}, inFlight: new Set() })), + processChallengeRounds: vi.fn().mockResolvedValue(0), + LlmFailureError, + }; +}); vi.mock("../src/utils.js", () => ({ sleep: vi.fn().mockResolvedValue(undefined), @@ -101,7 +111,7 @@ import { main, } from "../src/main.js"; import { loadIdentity } from "../src/identity.js"; -import { isLlmBusy } from "../src/llm.js"; +import { isLlmBusy, pingLlm } from "../src/llm.js"; import { secondsUntilDeadline, log } from "../src/utils.js"; import { processChallengeRounds } from "../src/capability-challenge.js"; @@ -327,6 +337,44 @@ describe("main", () => { mockCfg.node_role = "JUDGE"; }); + it("pauses worker after LlmFailureError and probes with ping before resuming", async () => { + mockClient.login.mockResolvedValue({}); + vi.mocked(isLlmBusy).mockReturnValue(false); + mockClient.getCapability.mockResolvedValue(CHALLENGER); + + // Cycle 1: runCycle throws LlmFailureError → inferenceDown = true. + // Cycle 2: ping returns false → skip work, inferenceDown stays true. + // Cycle 3: ping returns true → work resumes, we abort from inside runCycle. + const { LlmFailureError } = await import("../src/capability-challenge.js"); + const { processChallengeRounds } = await import("../src/capability-challenge.js"); + + let cycleCount = 0; + const ac = new AbortController(); + vi.mocked(processChallengeRounds).mockImplementation(async () => { + cycleCount++; + if (cycleCount === 1) throw new LlmFailureError(new Error("connection refused")); + if (cycleCount === 2) { + ac.abort(); + return 0; + } + return 0; + }); + vi.mocked(pingLlm) + .mockResolvedValueOnce(false) // cycle 2 probe — still down + .mockResolvedValueOnce(true); // cycle 3 probe — restored + + mockClient.getBalance.mockResolvedValue({ available: "0", challenge_locked: "250" }); + + await main(ac.signal); + + // processChallengeRounds called in cycles 1 and 3 (cycle 2 was skipped). + expect(processChallengeRounds).toHaveBeenCalledTimes(2); + expect(pingLlm).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Inference unavailable")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("still unavailable")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Inference restored")); + }); + it("goes idle (warning log, no reset) when Capable has balance < min_balance", async () => { mockClient.login.mockResolvedValue({}); vi.mocked(isLlmBusy).mockReturnValue(false); From 5c6d1b25b86df6a5a038684a2fd040b75d2f7733 Mon Sep 17 00:00:00 2001 From: AIvashov Date: Thu, 16 Apr 2026 16:17:13 +0700 Subject: [PATCH 06/13] Updated tui --- src/app.tsx | 81 ++++++++++------- src/bot.tsx | 222 ++++++++++++++++++++++++++++++++++------------ src/logo-mark.tsx | 53 +++++++++++ src/onboard.tsx | 38 ++++---- 4 files changed, 281 insertions(+), 113 deletions(-) create mode 100644 src/logo-mark.tsx diff --git a/src/app.tsx b/src/app.tsx index 6e80840..e5f07cf 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,28 +1,28 @@ +import { hostname, userInfo } from "node:os"; import { useState, useCallback, useRef } from "react"; -import { Box, Text, Static } from "ink"; +import { Box, Text } from "ink"; import { configExists, reloadConfig, get as getConfig } from "./config.js"; import { loadIdentity } from "./identity.js"; import { resetLlmClient } from "./llm.js"; import { COLORS } from "./constants.js"; import Onboard from "./onboard.js"; import BotScreen from "./bot.js"; - -const LOGO = [ - " ▒█████░ ▒█████░ █████████░ █████████░", - " ▓███████▓ ▓███████▓ █████████░ █████████░", - " ░█████████░ ░█████████░ █████████░ █████████░", - " ▓███████▓ ▓███████▓ █████████░ █████████░", - " ▒█████░ ▒█████░ █████████░ █████████░", - " █████████░ █████████░", - " ▒█████░ ▒█████░ █████████░ █████████░", - " ▓███████▓ ▓███████▓ █████████░ █████████░", - " ░█████████░ ░█████████░ █████████░ █████████░", - " ▓███████▓ ▓███████▓ █████████░ █████████░", - " ▒█████░ ▒█████░ █████████░ █████████░", -]; +import { LogoMark } from "./logo-mark.js"; type Screen = "onboard" | "register" | "running"; +function getShellPrompt(): string { + try { + const user = userInfo().username || "user"; + const host = hostname().split(".")[0] || "localhost"; + return `${user}@${host} ~ % fortytwo`; + } catch { + return "fortytwo"; + } +} + +const SHELL_PROMPT = getShellPrompt(); + function getInitialScreen(): Screen { if (!configExists()) return "onboard"; const cfg = getConfig(); @@ -33,16 +33,13 @@ function getInitialScreen(): Screen { export default function App() { const [screen, setScreen] = useState(getInitialScreen); const [botKey, setBotKey] = useState(0); + const [onboardStep, setOnboardStep] = useState<{ current: number; total: number; label: string } | null>(null); const handleSwitchProfile = useCallback(() => { resetLlmClient(); setBotKey((k) => k + 1); }, []); - const logoShownRef = useRef(false); - const showLogo = !logoShownRef.current && screen !== "running"; - if (showLogo) logoShownRef.current = true; - const fromCreateRef = useRef(false); const handleCreateProfile = useCallback(() => { @@ -52,11 +49,13 @@ export default function App() { const handleCancelCreate = useCallback(() => { fromCreateRef.current = false; + setOnboardStep(null); setScreen("running"); }, []); const handleOnboardDone = useCallback(() => { fromCreateRef.current = false; + setOnboardStep(null); reloadConfig(); resetLlmClient(); setBotKey((k) => k + 1); @@ -65,27 +64,41 @@ export default function App() { return ( - - {() => ( - - ╔═════════ WELCOME TO FORTYTWO, NETWORK NODE - - {LOGO.map((line, i) => ( - {line} - ))} - - ╚═════════ ONBOARDING + {screen !== "running" && ( + + {SHELL_PROMPT} + + + + + WELCOME TO + FORTYTWO + + NODERUNNER + + ONBOARDING + {onboardStep ? ( + + STEP {onboardStep.current}/{onboardStep.total}: {onboardStep.label} + + ) : null} + + - )} - + + )} - + {screen === "onboard" && ( - + )} {screen === "register" && ( - + )} {screen === "running" && ( diff --git a/src/bot.tsx b/src/bot.tsx index 20a9074..312f335 100644 --- a/src/bot.tsx +++ b/src/bot.tsx @@ -15,6 +15,7 @@ import { executeCommand, SUGGESTIONS } from "./commands.js"; import { validateConfig, validateModel } from "./setup-logic.js"; import { viewerBus } from "./event-bus.js"; import { checkForUpdate, UPDATE_COMMAND } from "./update-check.js"; +import { LogoMark } from "./logo-mark.js"; import pkg from "../package.json" with { type: "json" }; @@ -36,22 +37,57 @@ type AgentProfile = { const VERSION = pkg.version; -const LOGO = [ - " ▒██▓░ ▒██▓░ ░████▓░ ░████▓░", - " ░████▓░ ░████▓░ ░████▓░ ░████▓░", - " ▒██▓░ ▒██▓░ ░████▓░ ░████▓░", - " ░████▓░ ░████▓░", - " ▒██▓░ ▒██▓░ ░████▓░ ░████▓░", - " ░████▓░ ░████▓░ ░████▓░ ░████▓░", - " ▒██▓░ ▒██▓░ ░████▓░ ░████▓░", -]; - const MAX_LINES = 200; -// frame header(1) + empty(1) + logo(7) + empty(1) + frame footer(1) + gap(1) + separator(1) + prompt+footer(1) + gaps -const CHROME_LINES = 14; +// Header + metrics + separator + prompt + footer. +const CHROME_LINES = 16; + +function formatCapabilityRank(value: number | null): string { + if (value === null) return "—"; + const fixed = value.toFixed(4); + return fixed.replace(/\.?0+$/, ""); +} -function padRight(str: string, len: number): string { - return str.length >= len ? str : str + " ".repeat(len - str.length); +function buildCapabilityBar(value: number | null, total = 42): string { + if (value === null) return `[${"·".repeat(total)}]`; + + const clamped = Math.max(0, Math.min(value, total)); + const full = Math.floor(clamped); + const fractional = clamped - full; + const partial = fractional <= 0 + ? "" + : fractional <= 0.5 + ? "░" + : "▒"; + const empty = Math.max(0, total - full - (partial ? 1 : 0)); + return `[${"█".repeat(full)}${partial}${"·".repeat(empty)}]`; +} + +function fitLine(base: string, termCols: number, reserve = 0): string { + const max = Math.max(10, termCols - reserve); + if (base.length <= max) return base; + if (max <= 3) return base.slice(0, max); + return `${base.slice(0, max - 3)}...`; +} + +function padCell(label: string, value: string, width: number): string { + const cell = `${label} ${value}`; + return cell.length >= width ? `${cell} ` : `${cell}${" ".repeat(width - cell.length)}`; +} + +function makeColumnParts( + left: string, + right: string, + totalWidth: number, + leftWidth: number, +): { left: string; right: string } { + const safeTotal = Math.max(20, totalWidth); + const safeLeft = Math.max(10, Math.min(leftWidth, safeTotal - 6)); + const safeRight = Math.max(5, safeTotal - safeLeft - 1); + + const leftPart = fitLine(left, safeLeft); + const rightPart = fitLine(right, safeRight); + const gap = Math.max(1, safeLeft - leftPart.length + 1); + return { left: `${leftPart}${" ".repeat(gap)}`, right: rightPart }; } interface BotScreenProps { @@ -70,6 +106,8 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree const [capabilityRank, setCapabilityRank] = useState(null); const [nodeTier, setNodeTier] = useState<"challenger" | "capable" | null>(null); const [deadLocked, setDeadLocked] = useState(false); + const [runtimeStatus, setRuntimeStatus] = useState<"RUNNING" | "STOPPED">("STOPPED"); + const [activeDot, setActiveDot] = useState(-1); const [llmActive, setLlmActive] = useState(0); const [stats, setStats] = useState(null); const [profile, setProfile] = useState(null); @@ -96,6 +134,19 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree const [client, setClient] = useState(null); + useEffect(() => { + if (runtimeStatus !== "RUNNING") { + setActiveDot(-1); + return; + } + + setActiveDot(0); + const id = setInterval(() => { + setActiveDot((prev) => (prev + 1) % 4); + }, 230); + return () => clearInterval(id); + }, [runtimeStatus]); + const handleCommand = useCallback((input: string) => { const raw = input.trim(); if (!raw) return; @@ -303,6 +354,7 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree const cfg = getConfig(); const identity = loadIdentity(cfg.node_identity_file); if (!identity) { + setRuntimeStatus("STOPPED"); setError("No identity found. Run onboarding first."); return; } @@ -310,22 +362,23 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree // Validate config before proceeding const cfgCheck = validateConfig(cfg as unknown as Record); if (!cfgCheck.ok) { + setRuntimeStatus("STOPPED"); setError(`Config error: ${cfgCheck.error}`); return; } - log("Validating model..."); const modelCheck = await validateModel(cfg as unknown as Record); if (!modelCheck.ok) { + setRuntimeStatus("STOPPED"); setError(`Config error: ${modelCheck.error}`); return; } - log("✓ Configuration valid"); viewerBus.setState("AUTHENTICATING"); const c = new FortyTwoClient(); await c.login(identity.node_id, identity.node_secret); setClient(c); + setRuntimeStatus("RUNNING"); const name = cfg.node_name || cfg.node_display_name || "Agent"; setAgentName(name); @@ -347,12 +400,15 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree if (await pingLlm()) { log("✓ Inference restored, resuming work"); inferenceDown = false; + setRuntimeStatus("RUNNING"); } else { log("✕ Inference still unavailable — skipping cycle"); + setRuntimeStatus("STOPPED"); } } if (!inferenceDown) { + setRuntimeStatus("RUNNING"); const available = await checkBalance(c); if (!cancelled) setBalance(available); const capability = await fetchCapability(c); @@ -380,9 +436,11 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree const errMsg = (err as Error).message ?? String(err); if (err instanceof LlmFailureError) { inferenceDown = true; + setRuntimeStatus("STOPPED"); log(`⚠ Inference unavailable — pausing until ping succeeds. (${errMsg})`); viewerBus.pushError(`Inference unavailable: ${errMsg}`); } else { + setRuntimeStatus("STOPPED"); log(`✕ Error in cycle: ${err}`); viewerBus.pushError(errMsg); } @@ -406,11 +464,13 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree } } catch (err) { if (!cancelled) { + setRuntimeStatus("STOPPED"); setError(String(err)); viewerBus.setState("ERROR"); viewerBus.pushError(String(err)); } } finally { + setRuntimeStatus("STOPPED"); viewerBus.setRunning(false); } })(); @@ -430,7 +490,7 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree ? `Self-hosted ${cfg.self_hosted_api_base.replace(/^https?:\/\//, "").replace(/\/.*$/, "")}` : "OpenRouter"; - const displayName = truncateName(agentName.toUpperCase()); + const displayName = truncateName(agentName.toUpperCase(), 44); const intScore = profile ? formatNumber(profile.intelligenceScore, 4) : "—"; const jdgScore = profile ? formatNumber(profile.judgingScore, 3) : "—"; @@ -446,25 +506,39 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree const jRateStr = stats ? `${Math.round(stats.accuracy)}%` : "—"; const balStr = balance !== null ? formatNumber(balance) : "—"; const stakedStr = staked !== null ? formatNumber(staked) : "—"; - const lockedStr = challengeLocked !== null ? formatNumber(challengeLocked) : "—"; - const tierStr = nodeTier - ? nodeTier === "capable" - ? "Capable" - : capabilityRank !== null - ? `Challenger (${capabilityRank}/42)` - : "Challenger" - : "—"; - const tierColor = nodeTier === "capable" ? COLORS.BLUE_CONTENT : COLORS.GREY_LIGHT; - - // Progress bar for capability rank. Full at 42, hidden before capability is - // fetched. Width 16 cells. - const PROGRESS_WIDTH = 16; - const progressBar = capabilityRank !== null - ? (() => { - const filled = Math.round((Math.min(capabilityRank, 42) / 42) * PROGRESS_WIDTH); - return "█".repeat(filled) + "░".repeat(PROGRESS_WIDTH - filled); - })() - : null; + const panelWidth = Math.max(40, termCols - 8); + const columnLeftWidth = Math.min(40, Math.max(28, Math.floor(panelWidth * 0.55))); + const capRankStr = `${formatCapabilityRank(capabilityRank)}/42`; + const capBar = buildCapabilityBar(capabilityRank, 42); + const tierTitle = nodeTier === "capable" + ? "CAPABLE TIER" + : nodeTier === "challenger" + ? "CHALLENGER TIER" + : "NODE TIER"; + const tierDetail = nodeTier === "capable" + ? `INT ${intScore} · JDG ${jdgScore}` + : nodeTier === "challenger" + ? "PASS CAPABILITY CHALLENGE, UNLOCK FULL FUNCTIONALITY" + : "INITIALIZING"; + const tierColor = nodeTier === "capable" ? COLORS.WHITE : COLORS.GREY_LIGHT; + const tierTitleColor = nodeTier === "capable" ? COLORS.BLUE_CONTENT : COLORS.WHITE; + const statusTag = runtimeStatus === "STOPPED" ? "STOPPED" : ""; + const leftQ = `${padCell("Q", qStr, 12)}${padCell("fin", finStr, 12)}`; + const leftA = `${padCell("A", aStr, 12)}${padCell("won", aWonStr, 12)}rate ${aRateStr}`; + const leftJ = `${padCell("J", jStr, 12)}${padCell("won", jWonStr, 12)}rate ${jRateStr}`; + const scoreLine1 = makeColumnParts(leftQ, providerStr, panelWidth, columnLeftWidth); + const scoreLine2 = makeColumnParts(leftA, cfg.model_name, panelWidth, columnLeftWidth); + const scoreLine3 = makeColumnParts( + leftJ, + `Poll ${cfg.poll_interval}s Concurrency ${llmActive}/${cfg.llm_concurrency}`, + panelWidth, + columnLeftWidth, + ); + const capBarDisplay = fitLine( + capBar, + panelWidth - (statusTag ? statusTag.length + 1 : 0) - capRankStr.length - 1, + ); + const watchUrl = "http://127.0.0.1:4242/"; const versionText = ` App Fortytwo Client v${VERSION} ──`; const centerMarker = " ::|| "; @@ -475,32 +549,62 @@ export default function BotScreen({ onSwitchProfile, onCreateProfile }: BotScree return ( - ╔═════════ {displayName} · INT {intScore} · JDG {jdgScore} - - - {LOGO.map((line, i) => ( - {line} - ))} - - - {providerStr} - {cfg.model_name} - Poll {cfg.poll_interval}s · Concurrency {llmActive}/{cfg.llm_concurrency} - {padRight(`Q ${qStr}`, 14)}{padRight(`fin ${finStr}`, 14)} - {padRight(`A ${aStr}`, 14)}{padRight(`won ${aWonStr}`, 14)}{`rate ${aRateStr}`} - {padRight(`J ${jStr}`, 14)}{padRight(`won ${jWonStr}`, 14)}{`rate ${jRateStr}`} - FOR {balStr} locked {lockedStr} · staked {stakedStr} - Tier {tierStr} - {progressBar !== null && ( - Cap [{progressBar}] {capabilityRank}/42 - )} + + + + {fitLine(`${displayName} · ${roleDisplay}`, panelWidth)} + + + {tierTitle} + · {fitLine(tierDetail, panelWidth - tierTitle.length - 3)} + + + {statusTag ? {statusTag} : null} + {capBarDisplay} + {capRankStr} + + + + {scoreLine1.left} + {scoreLine1.right} + + + {scoreLine2.left} + {scoreLine2.right} + + + {scoreLine3.left} + {(() => { + const parsed = scoreLine3.right.match(/^Poll\s+(\S+)\s+Concurrency\s+(\S+)$/); + if (!parsed) { + return {scoreLine3.right}; + } + return ( + <> + Poll + {parsed[1]} + Concurrency + {parsed[2]} + + ); + })()} + + + + FOR + {balStr} + staked + {stakedStr} + + + WATCH YOUR NODE: + {watchUrl} + + {fitLine("", panelWidth)} - - - ╚═════════ {roleDisplay} | WATCH YOUR NODE: http://127.0.0.1:4242 - + {visible.map((line, i) => { const globalIdx = offset + i; const isCurrent = globalIdx === last && line.trim() !== ""; diff --git a/src/logo-mark.tsx b/src/logo-mark.tsx new file mode 100644 index 0000000..e22244a --- /dev/null +++ b/src/logo-mark.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from "ink"; +import { COLORS } from "./constants.js"; + +export type LogoTier = "challenger" | "capable" | null; + +interface LogoMarkProps { + tier?: LogoTier; + activeDot?: number; + height?: number; +} + +function dotGlyph(index: number, activeDot: number, tier: LogoTier): string { + if (index !== activeDot) return "●"; + if (tier === "capable") return "▲"; + return "■"; +} + +function dotColor(index: number, activeDot: number): string { + return index === activeDot ? COLORS.BLUE_CONTENT : COLORS.WHITE; +} + +export function LogoMark({ tier = null, activeDot = -1, height = 8 }: LogoMarkProps) { + const rows = Array.from({ length: Math.max(2, height) }, (_, idx) => idx); + + return ( + + {rows.map((row) => { + const barColor = row <= 1 ? COLORS.WHITE : COLORS.BLUE_FRAME; + return ( + + {row === 0 ? ( + <> + {dotGlyph(0, activeDot, tier)} + + {dotGlyph(1, activeDot, tier)} + + ) : row === 1 ? ( + <> + {dotGlyph(2, activeDot, tier)} + + {dotGlyph(3, activeDot, tier)} + + ) : ( + + )} + + █ █ + + ); + })} + + ); +} diff --git a/src/onboard.tsx b/src/onboard.tsx index 3190ab3..c81bc92 100644 --- a/src/onboard.tsx +++ b/src/onboard.tsx @@ -105,9 +105,10 @@ interface OnboardProps { onDone: () => void; skipToRegistration?: boolean; onCancel?: () => void; + onStepChange?: (step: { current: number; total: number; label: string } | null) => void; } -export default function Onboard({ onDone, skipToRegistration, onCancel }: OnboardProps) { +export default function Onboard({ onDone, skipToRegistration, onCancel, onStepChange }: OnboardProps) { const [stepIdx, setStepIdx] = useState(0); const [values, setValues] = useState>({}); const [inferenceType, setInferenceType] = useState(); @@ -127,6 +128,21 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar const step = steps[stepIdx]; const canGoBack = stepIdx > 0; + useEffect(() => { + if (!onStepChange) return; + if (!step) { + onStepChange(null); + return; + } + onStepChange({ + current: stepIdx + 1, + total: steps.length, + label: step.label.toUpperCase(), + }); + }, [onStepChange, stepIdx, step?.label, steps.length]); + + useEffect(() => () => onStepChange?.(null), [onStepChange]); + function goBack() { if (!canGoBack) return; const prevStep = steps[stepIdx - 1]; @@ -407,9 +423,6 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar if (phase === "validating_creds") { return ( - - STEP {stepIdx + 1}/{steps.length}: {step!.label.toUpperCase()} - {loader} Checking credentials... ); @@ -418,9 +431,6 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar if (phase === "fetching_models") { return ( - - STEP {stepIdx + 1}/{steps.length}: {step!.label.toUpperCase()} - {loader} Checking connection and fetching models... ); @@ -429,9 +439,6 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar if (phase === "validating") { return ( - - STEP {stepIdx + 1}/{steps.length}: {step!.label.toUpperCase()} - {loader} Checking model... ); @@ -443,7 +450,7 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar return ( - ▒▓░ {header} ░▓▒ + {header} {regLog.length === 0 && {loader} ⎔ Registering Node...} {regLog.map((line, i) => { @@ -462,19 +469,10 @@ export default function Onboard({ onDone, skipToRegistration, onCancel }: Onboar return ( - - STEP {stepIdx + 1}/{steps.length} - - {validationError && ( {validationError} )} - - {step!.label} - {step!.placeholder ? ({step!.placeholder}) : null} - - {isModelAutocomplete ? (() => { const MAX_SHOWN = 5; const visible = filteredModels.slice(0, MAX_SHOWN); From e350efefd09700e3a6a18f80ee8c2c16751c8be2 Mon Sep 17 00:00:00 2001 From: AIvashov Date: Thu, 16 Apr 2026 20:01:55 +0700 Subject: [PATCH 07/13] fixed color --- src/app.tsx | 84 ++++++++++++++++++++++++++++++++++++++++++++--- src/bot.tsx | 59 ++++++++++++++++++++++++--------- src/logo-mark.tsx | 47 +++++++++++++++++++------- src/onboard.tsx | 17 +++++++--- 4 files changed, 172 insertions(+), 35 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index e5f07cf..89d495b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,6 +1,7 @@ import { hostname, userInfo } from "node:os"; import { useState, useCallback, useRef } from "react"; import { Box, Text } from "ink"; +import { Select, ThemeProvider, extendTheme, defaultTheme } from "@inkjs/ui"; import { configExists, reloadConfig, get as getConfig } from "./config.js"; import { loadIdentity } from "./identity.js"; import { resetLlmClient } from "./llm.js"; @@ -8,8 +9,27 @@ import { COLORS } from "./constants.js"; import Onboard from "./onboard.js"; import BotScreen from "./bot.js"; import { LogoMark } from "./logo-mark.js"; +import { listProfiles, switchProfile } from "./profiles.js"; +import type { ProfileInfo } from "./profiles.js"; -type Screen = "onboard" | "register" | "running"; +type Screen = "profile_select" | "onboard" | "register" | "running"; + +const PROFILE_REGISTER_VALUE = "__register__"; +const PROFILE_IMPORT_VALUE = "__import__"; + +const selectTheme = extendTheme(defaultTheme, { + components: { + Select: { + styles: { + focusIndicator: () => ({ color: COLORS.WHITE }), + label: ({ isFocused }: { isFocused: boolean }) => ({ + color: isFocused ? COLORS.BLUE_CONTENT : undefined, + }), + selectedIndicator: () => ({ display: "none" as const }), + }, + }, + }, +}); function getShellPrompt(): string { try { @@ -24,6 +44,7 @@ function getShellPrompt(): string { const SHELL_PROMPT = getShellPrompt(); function getInitialScreen(): Screen { + if (listProfiles().length > 0) return "profile_select"; if (!configExists()) return "onboard"; const cfg = getConfig(); if (!cfg.node_identity_file || !loadIdentity(cfg.node_identity_file)) return "register"; @@ -34,6 +55,9 @@ export default function App() { const [screen, setScreen] = useState(getInitialScreen); const [botKey, setBotKey] = useState(0); const [onboardStep, setOnboardStep] = useState<{ current: number; total: number; label: string } | null>(null); + const [onboardMode, setOnboardMode] = useState<"new" | "import" | undefined>(); + const [profileError, setProfileError] = useState(null); + const [profileList, setProfileList] = useState(() => listProfiles()); const handleSwitchProfile = useCallback(() => { resetLlmClient(); @@ -44,31 +68,73 @@ export default function App() { const handleCreateProfile = useCallback(() => { fromCreateRef.current = true; + setOnboardMode(undefined); setScreen("onboard"); }, []); const handleCancelCreate = useCallback(() => { fromCreateRef.current = false; + setOnboardMode(undefined); setOnboardStep(null); setScreen("running"); }, []); const handleOnboardDone = useCallback(() => { fromCreateRef.current = false; + setOnboardMode(undefined); setOnboardStep(null); reloadConfig(); resetLlmClient(); + setProfileList(listProfiles()); setBotKey((k) => k + 1); setScreen("running"); }, []); + const handleProfileSelect = useCallback((value: string) => { + setProfileError(null); + if (value === PROFILE_REGISTER_VALUE) { + setOnboardMode("new"); + setScreen("onboard"); + return; + } + if (value === PROFILE_IMPORT_VALUE) { + setOnboardMode("import"); + setScreen("onboard"); + return; + } + if (!value.startsWith("profile:")) return; + + const profileName = value.slice("profile:".length); + try { + switchProfile(profileName); + reloadConfig(); + resetLlmClient(); + setBotKey((k) => k + 1); + setScreen("running"); + } catch (err) { + setProfileError(err instanceof Error ? err.message : String(err)); + } + }, []); + + const profileOptions = [ + { label: "Register new node", value: PROFILE_REGISTER_VALUE }, + { label: "Import existing node", value: PROFILE_IMPORT_VALUE }, + ...profileList.map((p) => { + const name = p.agentName || p.name; + const marker = p.active ? " (last used)" : ""; + return { label: `${name}${marker}`, value: `profile:${p.name}` }; + }), + ]; + const titleText = screen === "profile_select" ? "SELECT PROFILE" : "ONBOARDING"; + const logoHeight = screen === "profile_select" ? 4 : 5; + return ( {screen !== "running" && ( {SHELL_PROMPT} - + WELCOME TO @@ -76,8 +142,8 @@ export default function App() { NODERUNNER - ONBOARDING - {onboardStep ? ( + {titleText} + {screen !== "profile_select" && onboardStep ? ( STEP {onboardStep.current}/{onboardStep.total}: {onboardStep.label} @@ -89,8 +155,18 @@ export default function App() { )} + {screen === "profile_select" && ( + + {profileError ? {profileError} : null} + +