lot-11-E: deep analyse, corrections P0/P1, docs refonte, veille OSS

## Corrections P0 (critiques)
- node-engine-runner.js: JSON.parse try-catch sur JSONL (skip lignes invalides)
- storage.js: write queue par clé (Promise chain) anti-race condition
- ollama.js: cleanup event listener dans tous les chemins (success/error/abort)

## Corrections P1 (importants)
- server.js: nodeEngineQueue.stop() dans gracefulShutdown
- chat-routing.js: hard cap 500 entrées userRateLimits (LRU eviction)
- commands.js + http-api.js: timingSafeEqual pour ADMIN_TOKEN

## Documentation mise à jour
- ARCHITECTURE.md: RBAC ops→operator, mermaid persona state machine
- FEATURE_MAP.md: historique messages + DOM pruning marqués OK
- NODE_ENGINE_ARCHITECTURE.md: training adapters opérationnels (TRL/Unsloth)
- SPEC.md: table commandes slash (11 commandes)
- PROJECT_MEMORY.md: référence manifeste culturel
- OSS_WATCH: veille enrichie (20+ projets: Dify, CrewAI, Drizzle, vLLM, MCP...)
- PLAN.md: Phase C/D complétées, Phase E en cours

## Autoresearch (nouveau)
- scripts/v2-autoresearch-loop.js + config exemple + documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-16 15:55:02 +01:00
parent ac6c6114c3
commit e4bbe18017
17 changed files with 578 additions and 42 deletions
+39 -6
View File
@@ -171,7 +171,8 @@ Corrigé:
- P1 BUG-02: Timeout promise leak (AbortSignal cancel)
- P1 SEC-03: Attachment endpoints unauthenticated (requireAdminNetwork)
### Phase C — Feature parity V2 `[en cours8/10 fait]`
### Phase C — Feature parity V2 `[complété10/10]`
Livré:
- Recovery on crash (worker recoverStaleRuns + shouldCancel)
- Cancel support (requestCancel repo + API endpoint + worker callback)
@@ -181,11 +182,43 @@ Livré:
- Subnet gate V2 (CIDR middleware ADMIN_SUBNET)
- Retention sweep V2 (deleteOlderThan + POST /api/v2/admin/retention-sweep)
- Export HTML V2 (GET /api/v2/export/html)
- Upload fichiers V2 (bouton upload base64 Chat.tsx)
- Tab completion chat V2 (nicks + slash commands, Tab cycling)
- Historique messages V2 (ArrowUp/Down, 100 items max)
- DOM pruning V2 (élagage automatique à 500 messages)
### Phase D — Optimisation & polish
- [ ] Docker / docker-compose pour déploiement
- [ ] Documentation utilisateur
- [ ] Performance profiling hot paths
### Phase D — Déploiement & polish `[complété]`
Livré:
- Docker Compose reconfiguré (Ollama natif via extra_hosts, profils v1/v2/ollama)
- .env.example avec toutes les variables documentées
- .gitignore sécurisé (protection .env)
- Déploiement V2 sur kxkm-ai (API healthy port 4180, worker actif, Postgres container)
- README complet (démarrage dev/Docker, admin, architecture, variables)
### Phase E — Refonte globale `[en cours]`
#### E.1 — Deep analyse code V1+V2
- [x] Analyse systématique (25 findings: 5 P0, 10 P1, 10 P2)
- [ ] Corrections P0 chirurgicales (JSON crash, storage race, ollama leak)
- [ ] Corrections P1 chirurgicales (shutdown, rate limits, timingSafeEqual)
#### E.2 — Veille OSS mise à jour
- [x] Recherche web complète (20+ projets analysés)
- [x] docs/OSS_WATCH_2026-03-16.md enrichi (chat UI, orchestration, training, libs)
#### E.3 — Documentation & specs
- [ ] Mermaid persona editorial state machine ajouté
- [ ] FEATURE_MAP.md matrice de parité mise à jour
- [ ] ARCHITECTURE.md RBAC terminology fix (ops → operator)
- [ ] NODE_ENGINE_ARCHITECTURE.md training status mis à jour
- [ ] SPEC.md table commandes slash ajoutée
- [ ] PROJECT_MEMORY.md référence manifeste ajoutée
#### E.4 — Redéploiement
- [ ] Commit corrections + docs
- [ ] Push et redéployer sur kxkm-ai
+10
View File
@@ -294,6 +294,16 @@ function createChatRouter({
}
}
// Hard cap: if still above 500 after stale pruning, evict oldest entries
if (userRateLimits.size > 500) {
const sorted = [...userRateLimits.entries()]
.sort((a, b) => a[1].windowStart - b[1].windowStart);
const toRemove = sorted.slice(0, userRateLimits.size - 500);
for (const [key] of toRemove) {
userRateLimits.delete(key);
}
}
bucket.count++;
return bucket.count <= RATE_LIMIT_MAX;
}
+10 -1
View File
@@ -1,3 +1,12 @@
const { timingSafeEqual } = require("crypto");
function safeCompare(a, b) {
if (typeof a !== "string" || typeof b !== "string") return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
function createCommandHandler({
adminBootstrapToken,
admins,
@@ -884,7 +893,7 @@ function createCommandHandler({
if (typeof isAdminNetworkAllowed === "function" && !isAdminNetworkAllowed(info.clientIp)) {
return send(ws, "system", "*** Bootstrap admin refuse depuis ce reseau");
}
if (args[0] !== adminBootstrapToken) {
if (!safeCompare(args[0], adminBootstrapToken)) {
return send(ws, "system", "*** Token admin invalide");
}
claimOwnerNick(ws, info);
+17 -1
View File
@@ -247,6 +247,22 @@ flowchart LR
Proposals -- "revert" --> Overrides
```
### 4.4 Persona editorial state machine
Pipeline editorial de renforcement des personas via Pharmacius.
```mermaid
stateDiagram-v2
[*] --> idle
idle --> collecting : feedback reçu
collecting --> generating : seuil atteint
generating --> review : proposals générées
review --> applied : admin approuve
review --> reverted : admin rejette
applied --> idle : cycle terminé
reverted --> idle : cycle terminé
```
---
## 5. Stockage
@@ -312,7 +328,7 @@ Le systeme distingue quatre niveaux de privileges, configures via `config.js` :
| Role | Description | Source de configuration |
|------------|------------------------------------------------|----------------------------|
| `admin` | Acces complet, gestion personas et node-engine | `ADMINS` (liste de nicks) |
| `ops` | Operations, monitoring, acces TUI | `OPS` (liste de nicks) |
| `operator` | Operations, monitoring, acces TUI | `OPS` (liste de nicks) |
| `editor` | Modification personas (via admin UI) | Session admin authentifiee |
| `viewer` | Chat public, lecture seule | Tout client connecte |
+64
View File
@@ -0,0 +1,64 @@
# Autoresearch Mode V2
Ce document decrit une integration minimale de la logique autoresearch dans KXKM_Clown V2.
## Objectif
Automatiser des cycles d'experiences Node Engine avec un budget fixe par run, puis appliquer une decision keep/discard basee sur une politique de score deterministe.
Le mode actuel automatise l'orchestration des runs et leur selection. Il ne modifie pas le code des nodes.
## Ce qui est implemente
- script: scripts/v2-autoresearch-loop.js
- config exemple: ops/v2/autoresearch.example.json
- sortie TSV append-only: data/node-engine/autoresearch/results.tsv
Boucle executee:
1. creer un run queued sur un graph existant
2. attendre un statut terminal
3. calculer un score
4. marquer keep/discard par rapport au meilleur score courant
5. journaliser la ligne dans results.tsv
## Prerequis
- Postgres accessible via DATABASE_URL
- migrations V2 executees (tables node_graphs et node_runs)
- worker V2 actif (npm run dev:v2:worker) pour consommer la queue
- un graph existant dans node_graphs
## Utilisation
1. definir graphId dans ops/v2/autoresearch.example.json
2. lancer:
```bash
npm run v2:autoresearch
```
Execution unique:
```bash
node scripts/v2-autoresearch-loop.js --config ops/v2/autoresearch.example.json --once
```
## Politique de score
Le score par defaut est derive du statut terminal, avec bonus de vitesse pour les runs completes:
- completed: 1 + bonus
- failed: 0
- cancelled, blocked, not_configured: -1
La table statusScores du JSON permet d'ajuster le comportement sans changer le script.
## Limites actuelles
- pas encore de metriques metier (qualite persona, cout tokens, latence p95)
- keep/discard est une decision de session, pas encore un alias model registry
- pas de mutation automatique des graphes ou hyperparametres
## Etape suivante recommandee
Ajouter un node d'evaluation canonique qui produit un score metier dans un artefact persiste, puis brancher ce score comme source principale de decision dans la boucle autoresearch.
+2 -2
View File
@@ -153,8 +153,8 @@ flowchart TD
| Multi-canaux | OK | OK | haute |
| Streaming LLM | OK | OK | haute |
| Tab completion | OK | OK | moyenne |
| Historique messages | OK | prevu | basse |
| DOM pruning | OK | prevu | basse |
| Historique messages | OK | OK | basse |
| DOM pruning | OK | OK | basse |
| Upload fichiers | OK | OK | moyenne |
| Commandes slash | OK | OK | moyenne |
| **Admin** | | | |
+4 -4
View File
@@ -153,9 +153,8 @@ Règles:
- exécution réelle des nodes dataset/processing/evaluation/registry/deploy
- queue persistée avec reprise automatique des runs `queued/running`
- annulation coopérative au step boundary
- training via adaptateurs externes ou statut `not_configured`
- training via adaptateurs réels TRL + Unsloth (Lot 10) — opérationnel
- persistance des runs, étapes, artefacts et modèles enregistrés
- prochaine étape: adaptateurs training réels et runtimes distants branchés
## Intégration V2
@@ -279,10 +278,11 @@ Invariants:
- statuts `queued`, `running`, `completed`, `failed`
État:
- partiellement livré
- livré
- runner local réel, queue async persistée et runtimes déclarés en place
- training encore dépendant d'adaptateurs externes
- adaptateurs training réels TRL + Unsloth livrés (Lot 10)
- asynchronie de base livrée; orchestration avancée encore ouverte
- runtimes `remote_gpu`, `cluster`, `cloud_api` déclarés mais pas encore opérationnels
### V3 — Runtimes distants et déploiement
- `remote_gpu`, `cluster`, `cloud_api`
+84 -12
View File
@@ -2,7 +2,7 @@
## Scope
Veille ciblee sur des projets open source proches de KXKM_Clown (chat local LLM, orchestration DAG, editeur nodal, queue jobs, inference locale).
Veille ciblee sur des projets open source proches de KXKM_Clown (chat local LLM, orchestration DAG, editeur nodal, queue jobs, inference locale, training, persona/agent management).
## Sources
@@ -12,21 +12,93 @@ Veille ciblee sur des projets open source proches de KXKM_Clown (chat local LLM,
- https://github.com/FlowiseAI/Flowise
- https://github.com/mudler/LocalAI
- https://nodered.org/
- https://github.com/open-webui/open-webui
- https://github.com/danny-avila/LibreChat
- https://github.com/lobehub/lobe-chat
- https://github.com/langgenius/dify
- https://github.com/langchain-ai/langgraph
- https://github.com/langflow-ai/langflow
- https://github.com/crewAIInc/crewAI
- https://github.com/microsoft/autogen
- https://github.com/unslothai/unsloth
- https://github.com/axolotl-ai-cloud/axolotl
- https://github.com/hiyouga/LLaMA-Factory
- https://github.com/drizzle-team/drizzle-orm
- https://github.com/vllm-project/vllm
- https://modelcontextprotocol.io/
## Findings
### Chat UI & Multi-Persona
| Projet | Signal observe | Usage potentiel |
|---|---|---|
| ollama-js | release stable v0.6.3, streaming AsyncGenerator, tools/embed/abort | deja aligne avec le repo, a conserver |
| BullMQ | v5.71.0, parent-child flow, rate-limits, retries, ecosystem mature | candidat pour scaling queue Node Engine |
| Rete.js | v2.0.6, dataflow + control flow | candidat si NodeEditor devient plus complexe |
| Flowise | v3.0.13, UX agent/workflow builder mature | source d inspiration UX produit |
| LocalAI | v4.0.0 recent, multimodal + API drop-in style OpenAI | plan B/extension multimodale locale |
| Node-RED | v4.1.7, patterns event-driven industrialises | patterns d orchestration/reliability |
| --- | --- | --- |
| Open WebUI | 126K stars, RLHF annotation, multi-user | inspiration UI/UX, feedback collection |
| LibreChat | 34K stars, multi-provider, conversation forking | inspiration API multi-provider |
| LobeChat | UI tres polie, animations, production-ready | reference design frontend |
| Dify | DAG workflow + agents, 1.4M deployments, $30M funding | inspiration Node Engine + agent workflows |
### Orchestration DAG
| Projet | Signal observe | Usage potentiel |
| --- | --- | --- |
| ollama-js | v0.6.3 stable, streaming AsyncGenerator, tools/embed/abort | deja aligne, a conserver |
| BullMQ | v5.71.0, parent-child flow, rate-limits, retries | candidat scaling queue Node Engine |
| Rete.js | v2.0.6, dataflow + control flow | candidat si NodeEditor plus complexe |
| Flowise | v3.0.13, Node.js, UX agent/workflow builder | source inspiration UX + architecture |
| LocalAI | v4.0.0, multimodal + API OpenAI drop-in | plan B inference multimodale |
| Node-RED | v4.1.7, patterns event-driven industrialises | patterns orchestration/reliability |
| LangGraph | state machines, cycles, error handling | inspiration error recovery Node Engine |
| Langflow | DAG visual, export JSON/Python, MCP deploy | inspiration UI/export workflows |
| n8n | 34K stars, AI Agent builder, 400+ integrations | inspiration AI nodes + workflow |
### Persona / Agent Management
| Projet | Signal observe | Usage potentiel |
| --- | --- | --- |
| CrewAI | role-based agents, crew metaphor, task orchestration | inspiration architecture multi-persona |
| AutoGen | Microsoft, multi-agent conversations, layered APIs | reference conversation multi-agent |
### Training / Fine-Tuning
| Projet | Signal observe | Usage potentiel |
| --- | --- | --- |
| TRL | HuggingFace, DPO/PPO/GRPO natif | deja dans la stack, continuer |
| Unsloth | 2x plus rapide, 80% moins memoire, Triton | optimisation training pipeline |
| Axolotl | config-driven, reproductibilite, DPO+SFT+PPO | complement reliability pipeline |
| LLaMA Factory | LoRA, DPO, KTO, ORPO, UI incluse | alternative diversification training |
### Librairies Stack
| Projet | Signal observe | Usage potentiel |
| --- | --- | --- |
| Drizzle ORM | TypeScript-first, 10-20% overhead vs raw SQL | migration candidate vs pg brut |
| React Flow | v12, deja utilise (@xyflow/react) | stack actuelle, conserver |
| ws | v8.19, 17.7M users | stack actuelle, conserver |
| uWebSockets.js | C++ core, haute concurrence | si scaling > 1000 connexions |
| Blessed/neo-blessed | TUI ncurses en JS | stack actuelle TUI |
| Ink | React-like TUI, JSX | alternative TUI pour nouveaux outils |
| MCP | Anthropic, standard ouvert, SDK TypeScript | integration future personas ↔ outils |
| vLLM | PagedAttention, 2-4x throughput | complement si multi-user concurrent |
## Recommandations
1. Court terme: garder la stack actuelle (React Flow + queue custom + ollama-js).
2. Moyen terme: faire un spike BullMQ pour scenarios a charge/redistribution.
3. Moyen terme: faire un spike Rete.js si besoin de control flow visuel avance.
4. Veille continue: suivre LocalAI pour extension voix/image et fallback inference locale.
### Court terme (maintenir)
1. Garder la stack actuelle (React Flow + queue custom + ollama-js + ws + blessed)
2. Continuer TRL pour le training DPO
### Moyen terme (evaluer)
3. Spike BullMQ pour scenarios charge/redistribution queue Node Engine
4. Spike Drizzle ORM pour remplacer les requetes pg brutes (meilleur type safety)
5. Spike Unsloth pour accelerer le training pipeline (2x plus rapide)
6. Etudier Axolotl pour reproductibilite des runs de training
7. Spike Rete.js si besoin control flow visuel avance
### Long terme (surveiller)
8. Implementer MCP pour integration standardisee personas ↔ outils externes
9. Evaluer vLLM si scaling multi-user concurrent depasse Ollama
10. Suivre Dify pour inspiration Node Engine + workflow agent
11. Suivre LocalAI pour extension voix/image et fallback inference locale
+7
View File
@@ -19,6 +19,13 @@ Produit prive multi-utilisateur, architecture locale, identite IRC forte, Node E
- Validation operee: lot-2-domaines relance et passe en done
- Hygiene appliquee: logs lot-2 analyses puis purges, outputs CSV conserves
## Fondation culturelle — Manifeste
Le projet repose sur deux fichiers manifeste qui definissent l'univers de references et les easter eggs:
- `data/manifeste.md` — Easter eggs, références culturelles (Hitchhiker's Guide, Blade Runner, SF, musique concrète, demoscene, funk)
- `data/manifeste_references_nouvelles.md` — Références étendues en 9 catégories (crypto-anarchisme, afrofuturisme, noise/industrial, cyberfeminisme, situationnisme, open source, net.art, éco-anarchisme)
## Prochain focus
- lot-3-surfaces
+16
View File
@@ -61,3 +61,19 @@ sequenceDiagram
- Pas de melange runtime editorial et exports training
- Pas d ouverture internet par defaut
- Toute mutation admin doit etre auditable
## Commandes slash
| Commande | Description | Admin |
|----------|-------------|-------|
| `/help` | Aide | non |
| `/clear` | Effacer le chat | non |
| `/nick` | Changer pseudo | non |
| `/join` | Rejoindre un canal | non |
| `/msg` | Message privé | non |
| `/web` | Recherche web | non |
| `/status` | Statut système | non |
| `/model` | Changer modèle | oui |
| `/persona` | Gérer personas | oui |
| `/reload` | Recharger config | oui |
| `/export` | Exporter données | oui |
+10 -1
View File
@@ -1,6 +1,15 @@
const path = require("path");
const { timingSafeEqual } = require("crypto");
const { MAX_UPLOAD_BYTES } = require("./attachment-pipeline");
function safeCompare(a, b) {
if (typeof a !== "string" || typeof b !== "string") return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
function registerApiRoutes(app, {
adminBootstrapToken,
host,
@@ -274,7 +283,7 @@ function registerApiRoutes(app, {
}
const token = String(req.body?.token || "").trim();
if (token !== adminBootstrapToken) {
if (!safeCompare(token, adminBootstrapToken)) {
return res.status(403).json({ error: "invalid admin bootstrap token" });
}
+9 -1
View File
@@ -77,7 +77,15 @@ function readFileDataset(rootDir, inputPath) {
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
.map((line, index) => {
try {
return JSON.parse(line);
} catch (e) {
console.warn(`[readFileDataset] Skipping invalid JSONL line ${index + 1}: ${e.message}`);
return null;
}
})
.filter(Boolean);
} else if (format === "json") {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) items = parsed;
+12 -4
View File
@@ -48,14 +48,22 @@ function createOllamaClient({
async function ollamaChat(model, messages, onToken, abortSignal, tokenLimit) {
const controller = new AbortController();
let onAbort;
if (abortSignal) {
const onAbort = () => controller.abort();
onAbort = () => controller.abort();
abortSignal.addEventListener("abort", onAbort, { once: true });
}
// Global timeout: abort if chat takes too long overall
const chatTimer = setTimeout(() => controller.abort(), OLLAMA_CHAT_TIMEOUT_MS);
function cleanup() {
clearTimeout(chatTimer);
if (abortSignal && onAbort) {
abortSignal.removeEventListener("abort", onAbort);
}
}
const numPredict = tokenLimit || (model.includes("mistral") ? maxResponseTokensSmall : maxResponseTokens);
let fullResponse = "";
@@ -75,7 +83,7 @@ function createOllamaClient({
if (fullResponse.length > maxResponseChars) {
controller.abort();
onToken("", true, null);
clearTimeout(chatTimer);
cleanup();
return fullResponse.slice(0, maxResponseChars);
}
onToken(chunk.message.content, chunk.done || false);
@@ -89,7 +97,7 @@ function createOllamaClient({
}
}
} catch (e) {
clearTimeout(chatTimer);
cleanup();
if (e.name === "AbortError") {
onToken("", true, null);
return fullResponse;
@@ -97,7 +105,7 @@ function createOllamaClient({
throw e;
}
clearTimeout(chatTimer);
cleanup();
return fullResponse;
}
+15
View File
@@ -0,0 +1,15 @@
{
"graphId": "graph_replace_me",
"maxExperiments": 12,
"runTimeoutMs": 300000,
"pollIntervalMs": 2000,
"statusScores": {
"completed": 1,
"failed": 0,
"cancelled": -1,
"blocked": -1,
"not_configured": -1
},
"outputFile": "data/node-engine/autoresearch/results.tsv",
"tag": "persona-baseline"
}
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* V2 Autoresearch Loop (minimal, DB-driven)
*
* Runs fixed-budget experiment cycles on an existing Node Engine graph by:
* - creating queued runs
* - waiting for terminal status
* - scoring outcomes with a deterministic policy
* - tracking best candidate across the session
*
* This script does not mutate graph code. It automates run orchestration and
* keep/discard decisions at the run level.
*/
const fs = require("fs");
const path = require("path");
const { Pool } = require("pg");
function readArg(flag) {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : "";
}
function hasFlag(flag) {
return process.argv.includes(flag);
}
function nowIso() {
return new Date().toISOString();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function loadConfig(configPath) {
const resolved = path.resolve(process.cwd(), configPath);
const raw = fs.readFileSync(resolved, "utf8");
const cfg = JSON.parse(raw);
if (!cfg || typeof cfg !== "object") {
throw new Error("Invalid config: expected object");
}
if (typeof cfg.graphId !== "string" || !cfg.graphId) {
throw new Error("Invalid config: graphId is required");
}
return {
graphId: cfg.graphId,
maxExperiments: Number.isFinite(cfg.maxExperiments) ? cfg.maxExperiments : 12,
runTimeoutMs: Number.isFinite(cfg.runTimeoutMs) ? cfg.runTimeoutMs : 5 * 60 * 1000,
pollIntervalMs: Number.isFinite(cfg.pollIntervalMs) ? cfg.pollIntervalMs : 2000,
statusScores: cfg.statusScores && typeof cfg.statusScores === "object"
? cfg.statusScores
: {
completed: 1,
failed: 0,
cancelled: -1,
blocked: -1,
not_configured: -1,
},
outputFile: typeof cfg.outputFile === "string" && cfg.outputFile
? cfg.outputFile
: "data/node-engine/autoresearch/results.tsv",
tag: typeof cfg.tag === "string" ? cfg.tag : "default",
};
}
function scoreRun(status, elapsedMs, statusScores) {
const base = Number(statusScores[status]);
const safeBase = Number.isFinite(base) ? base : -1;
const speedBonus = safeBase > 0 ? Math.max(0, 1 - elapsedMs / (10 * 60 * 1000)) : 0;
return safeBase + speedBonus;
}
function ensureTsvHeader(filePath) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(filePath)) {
fs.writeFileSync(
filePath,
[
"timestamp\texperiment\trun_id\tgraph_id\tstatus\telapsed_ms\tscore\tdecision\ttag",
].join("\n") + "\n",
"utf8",
);
}
}
function appendTsv(filePath, row) {
fs.appendFileSync(filePath, row.join("\t") + "\n", "utf8");
}
async function ensureGraphExists(pool, graphId) {
const result = await pool.query(
"SELECT id, name FROM node_graphs WHERE id = $1 LIMIT 1",
[graphId],
);
if (result.rows.length === 0) {
throw new Error("Graph not found: " + graphId);
}
return result.rows[0];
}
async function createQueuedRun(pool, graphId, params) {
const runId = "run_" + Math.random().toString(36).slice(2, 10);
const createdAt = nowIso();
await pool.query(
`INSERT INTO node_runs (id, graph_id, status, params, created_at, updated_at)
VALUES ($1, $2, 'queued', $3::jsonb, $4, NOW())`,
[runId, graphId, JSON.stringify(params || {}), createdAt],
);
return { id: runId, createdAt };
}
async function getRunStatus(pool, runId) {
const result = await pool.query(
"SELECT id, status, created_at, updated_at FROM node_runs WHERE id = $1 LIMIT 1",
[runId],
);
if (result.rows.length === 0) {
throw new Error("Run not found: " + runId);
}
return result.rows[0];
}
async function cancelRun(pool, runId) {
await pool.query(
`UPDATE node_runs
SET status = 'cancelled', updated_at = NOW()
WHERE id = $1 AND status IN ('queued', 'running')`,
[runId],
);
}
async function waitForTerminalStatus(pool, runId, timeoutMs, pollIntervalMs) {
const terminal = new Set(["completed", "failed", "cancelled", "blocked", "not_configured"]);
const started = Date.now();
while (true) {
const row = await getRunStatus(pool, runId);
const status = String(row.status);
if (terminal.has(status)) {
return {
status,
elapsedMs: Date.now() - started,
timedOut: false,
};
}
const elapsed = Date.now() - started;
if (elapsed >= timeoutMs) {
await cancelRun(pool, runId);
return {
status: "cancelled",
elapsedMs: elapsed,
timedOut: true,
};
}
await sleep(pollIntervalMs);
}
}
async function main() {
const configPath = readArg("--config") || "ops/v2/autoresearch.example.json";
const once = hasFlag("--once");
const config = loadConfig(configPath);
const outputPath = path.resolve(process.cwd(), config.outputFile);
ensureTsvHeader(outputPath);
const connectionString = process.env.DATABASE_URL || "postgres://localhost:5432/kxkm_clown_v2";
const pool = new Pool({ connectionString });
let bestScore = Number.NEGATIVE_INFINITY;
let bestRunId = "";
try {
const graph = await ensureGraphExists(pool, config.graphId);
console.log("[autoresearch] graph=" + graph.id + " name=" + graph.name);
const total = once ? 1 : config.maxExperiments;
for (let i = 1; i <= total; i += 1) {
const experimentTag = "exp_" + String(i).padStart(3, "0");
const run = await createQueuedRun(pool, config.graphId, {
autoresearch: {
tag: config.tag,
experiment: experimentTag,
createdBy: "scripts/v2-autoresearch-loop.js",
},
});
console.log("[autoresearch] queued " + run.id + " (" + experimentTag + ")");
const outcome = await waitForTerminalStatus(
pool,
run.id,
config.runTimeoutMs,
config.pollIntervalMs,
);
const score = scoreRun(outcome.status, outcome.elapsedMs, config.statusScores);
const isBest = score > bestScore;
const decision = isBest ? "keep" : "discard";
if (isBest) {
bestScore = score;
bestRunId = run.id;
}
appendTsv(outputPath, [
nowIso(),
experimentTag,
run.id,
config.graphId,
outcome.status,
String(outcome.elapsedMs),
score.toFixed(6),
decision,
config.tag,
]);
const suffix = outcome.timedOut ? " timeout" : "";
console.log(
"[autoresearch] " + run.id + " status=" + outcome.status +
" score=" + score.toFixed(4) + " decision=" + decision + suffix,
);
}
console.log(
"[autoresearch] done best_run=" + (bestRunId || "none") +
" best_score=" + (Number.isFinite(bestScore) ? bestScore.toFixed(4) : "n/a"),
);
console.log("[autoresearch] results=" + outputPath);
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error("[autoresearch] fatal", err instanceof Error ? err.message : String(err));
process.exit(1);
});
+1
View File
@@ -331,6 +331,7 @@ const retentionIntervalId = setInterval(() => {
function gracefulShutdown(signal) {
console.log(`\n[shutdown] ${signal} received, saving sessions...`);
sessionManager.stop();
nodeEngineQueue.stop();
clearInterval(retentionIntervalId);
sessionManager.saveAllSessions();
+32 -10
View File
@@ -11,6 +11,18 @@ function createStorage(dataDir, {
const logsDir = path.join(dataDir, "logs");
const MAX_SESSION_MESSAGES = 400;
// Per-key write queue to prevent concurrent file corruption
const _writeQueues = new Map();
function _enqueue(key, fn) {
const prev = _writeQueues.get(key) || Promise.resolve();
const next = prev.then(fn, fn);
_writeQueues.set(key, next);
next.then(() => {
if (_writeQueues.get(key) === next) _writeQueues.delete(key);
});
return next;
}
function cleanText(value, maxLength = 4000) {
return String(value || "").trim().slice(0, maxLength);
}
@@ -75,12 +87,20 @@ function createStorage(dataDir, {
const safeRole = String(role || "").replace(/[^a-z0-9_-]/gi, "_").slice(0, 20);
const ts = new Date().toISOString();
const line = `[${ts}] [${safeChan}] <${safeRole}> ${text.slice(0, 2000)}\n`;
try {
fs.appendFileSync(path.join(logsDir, `nick_${safe}.log`), line);
fs.appendFileSync(path.join(logsDir, `${safeChan}.log`), `[${ts}] <${safeRole}> ${text.slice(0, 2000)}\n`);
} catch (e) {
console.error("[log] write error:", e.message);
}
const chanLine = `[${ts}] <${safeRole}> ${text.slice(0, 2000)}\n`;
return _enqueue(`log:nick_${safe}`, () => {
try {
fs.appendFileSync(path.join(logsDir, `nick_${safe}.log`), line);
} catch (e) {
console.error("[log] write error:", e.message);
}
}).then(() => _enqueue(`log:${safeChan}`, () => {
try {
fs.appendFileSync(path.join(logsDir, `${safeChan}.log`), chanLine);
} catch (e) {
console.error("[log] write error:", e.message);
}
}));
}
function logDPOPair(nick, prompt, chosen, rejected, chosenModel, rejectedModel) {
@@ -225,11 +245,13 @@ function createStorage(dataDir, {
}
function saveSession(id, session) {
if (!session) return;
if (!session) return Promise.resolve();
const safeId = String(id || "").replace(/[^a-z0-9_#:-]/gi, "_").slice(0, 180);
if (!safeId) return;
const file = path.join(sessionsDir, `${safeId}.json`);
fs.writeFileSync(file, JSON.stringify(session, (k, v) => k.startsWith("_") ? undefined : v, 2));
if (!safeId) return Promise.resolve();
return _enqueue(`session:${safeId}`, () => {
const file = path.join(sessionsDir, `${safeId}.json`);
fs.writeFileSync(file, JSON.stringify(session, (k, v) => k.startsWith("_") ? undefined : v, 2));
});
}
function normalizeSessionSnapshot(session) {