@splinterzzz/ouro 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
flash white around a near-black app on load. -->
|
|
10
10
|
<meta name="theme-color" content="#0b0a12" />
|
|
11
11
|
<title>ouro</title>
|
|
12
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
+
<script type="module" crossorigin src="/assets/index-Btdawh-I.js"></script>
|
|
13
13
|
<link rel="stylesheet" crossorigin href="/assets/index-C21ZXjPS.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body>
|
package/package.json
CHANGED
package/src/lib/config.js
CHANGED
|
@@ -16,6 +16,10 @@ const DEFAULTS = {
|
|
|
16
16
|
// explicit "Create PR" button instead — pushing is outward-facing, so this
|
|
17
17
|
// is deliberately a knob and not a hardcode.
|
|
18
18
|
autoShip: true,
|
|
19
|
+
// Ceiling on agent-loop QA loop-back re-runs (enterStaging) before the ticket
|
|
20
|
+
// escalates to a human — a safety valve against a runaway loop that keeps
|
|
21
|
+
// failing QA and re-running the (expensive) engineer forever.
|
|
22
|
+
maxQaAttempts: 3,
|
|
19
23
|
telegram: {
|
|
20
24
|
botTokenEnvVar: "OURO_TELEGRAM_BOT_TOKEN",
|
|
21
25
|
chatIdEnvVar: "OURO_TELEGRAM_CHAT_ID",
|
|
@@ -94,6 +98,11 @@ export function getAutoShip() {
|
|
|
94
98
|
return readConfig().autoShip !== false;
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
export function getMaxQaAttempts() {
|
|
102
|
+
const n = readConfig().maxQaAttempts;
|
|
103
|
+
return Number.isInteger(n) && n >= 1 ? n : 3;
|
|
104
|
+
}
|
|
105
|
+
|
|
97
106
|
/** Staging config, each field null when unset (ouro then resolves it per-repo). */
|
|
98
107
|
export function getStaging() {
|
|
99
108
|
const s = readConfig().staging ?? {};
|
package/src/lib/daemon.js
CHANGED
|
@@ -60,9 +60,24 @@ export function updateRecord(name, patch) {
|
|
|
60
60
|
return next;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Signal 0 probes for existence without delivering anything. On win32 this is
|
|
65
|
+
* unreliable on its own: PIDs recycle fast, and once our dead pid is reused by
|
|
66
|
+
* an unrelated process owned by another user/session, `process.kill(pid, 0)`
|
|
67
|
+
* throws EPERM (exists, just not ours) — indistinguishable from our own
|
|
68
|
+
* still-alive service. That false positive pins a stale pid file forever
|
|
69
|
+
* (`stop` reports "refused to die" against a ghost). Cross-check with
|
|
70
|
+
* `tasklist` so a recycled pid isn't mistaken for our service.
|
|
71
|
+
*/
|
|
64
72
|
export function isAlive(pid) {
|
|
65
73
|
if (!pid) return false;
|
|
74
|
+
if (process.platform === "win32") {
|
|
75
|
+
const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
|
|
76
|
+
encoding: "utf-8",
|
|
77
|
+
});
|
|
78
|
+
const out = result.stdout || "";
|
|
79
|
+
return out.includes(`"${pid}"`);
|
|
80
|
+
}
|
|
66
81
|
try {
|
|
67
82
|
process.kill(pid, 0);
|
|
68
83
|
return true;
|
package/src/server/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { contextManifest, listContextFiles, listReferencedFiles } from "../lib/a
|
|
|
10
10
|
import { appendRunLog, readRunLog } from "../lib/ouroLog.js";
|
|
11
11
|
import { runTests } from "../lib/staging.js";
|
|
12
12
|
import { startPreview, stopPreview, previewInfo } from "../lib/preview.js";
|
|
13
|
-
import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, telegramTokenVar } from "../lib/config.js";
|
|
13
|
+
import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, getMaxQaAttempts, telegramTokenVar } from "../lib/config.js";
|
|
14
14
|
import { readEnvVars, writeEnvVars } from "../lib/env.js";
|
|
15
15
|
import { looksLikeToken, maskToken, verifyBotToken } from "../lib/telegram.js";
|
|
16
16
|
import { startService, stopService, serviceStatus, isAlive, tailLog, uptime } from "../lib/daemon.js";
|
|
@@ -339,9 +339,10 @@ export function createServer() {
|
|
|
339
339
|
appendRunLog(store.get(ticketId), shipOutcome(shipResult));
|
|
340
340
|
return;
|
|
341
341
|
}
|
|
342
|
-
// Not ready. Loop-stop guard:
|
|
343
|
-
// regardless of mode —
|
|
344
|
-
|
|
342
|
+
// Not ready. Loop-stop guard: once we hit the configurable ceiling
|
|
343
|
+
// (maxQaAttempts) the ticket escalates to a human, regardless of mode —
|
|
344
|
+
// don't loop forever re-running the expensive engineer.
|
|
345
|
+
if (attempt >= getMaxQaAttempts()) {
|
|
345
346
|
store.update(ticketId, { awaitingQa: true, escalated: true });
|
|
346
347
|
appendRunLog(store.get(ticketId), `QA failed ${attempt}× — escalated to human`);
|
|
347
348
|
return;
|
|
@@ -361,36 +362,29 @@ export function createServer() {
|
|
|
361
362
|
}
|
|
362
363
|
}
|
|
363
364
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
app.post("/api/tickets/:id/analyze", async (req, res) => {
|
|
377
|
-
const ticket = store.get(req.params.id);
|
|
378
|
-
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
379
|
-
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
|
|
365
|
+
/**
|
|
366
|
+
* The read-only Analyst pass, factored out of its route so the auto-pipeline
|
|
367
|
+
* can chain it into a run without a second HTTP hop. Owns its own run
|
|
368
|
+
* registration and the transient `analyzing` flag, and returns `{ ok }` so a
|
|
369
|
+
* caller can decide whether to proceed — abort or a caught error is `ok:false`
|
|
370
|
+
* (the error is still logged to the ticket, as when a human clicked Analyze).
|
|
371
|
+
*/
|
|
372
|
+
async function runAnalyze(ticketId) {
|
|
373
|
+
const ticket = store.get(ticketId);
|
|
374
|
+
if (!ticket || runs.isRunning(ticketId)) return { ok: false };
|
|
380
375
|
|
|
381
376
|
let signal;
|
|
382
377
|
try {
|
|
383
|
-
signal = runs.begin(
|
|
384
|
-
} catch
|
|
385
|
-
return
|
|
378
|
+
signal = runs.begin(ticketId, "analyze");
|
|
379
|
+
} catch {
|
|
380
|
+
return { ok: false };
|
|
386
381
|
}
|
|
387
382
|
|
|
388
|
-
res.json({ started: true });
|
|
389
383
|
// Analyze is read-only and leaves the ticket where it is; a transient flag
|
|
390
384
|
// drives the card's "Analyzing…" state without faking a worktree run.
|
|
391
|
-
store.update(
|
|
385
|
+
store.update(ticketId, { analyzing: true });
|
|
392
386
|
|
|
393
|
-
const onEvent = (event) => store.appendLog(
|
|
387
|
+
const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
|
|
394
388
|
|
|
395
389
|
try {
|
|
396
390
|
// Always the Analyst agent for this step, regardless of the ticket's
|
|
@@ -406,8 +400,8 @@ export function createServer() {
|
|
|
406
400
|
onEvent,
|
|
407
401
|
agent: analyst,
|
|
408
402
|
});
|
|
409
|
-
if (signal.aborted) return; // cancelled mid-analysis — /cancel already reconciled it
|
|
410
|
-
store.update(
|
|
403
|
+
if (signal.aborted) return { ok: false }; // cancelled mid-analysis — /cancel already reconciled it
|
|
404
|
+
store.update(ticketId, {
|
|
411
405
|
status: "analyzed",
|
|
412
406
|
analyzing: false,
|
|
413
407
|
summary: result.summary,
|
|
@@ -415,51 +409,39 @@ export function createServer() {
|
|
|
415
409
|
filesLikelyAffected: Array.isArray(result.files_likely_affected) ? result.files_likely_affected : [],
|
|
416
410
|
acceptanceCriteria: Array.isArray(result.acceptance_criteria) ? result.acceptance_criteria : [],
|
|
417
411
|
});
|
|
412
|
+
return { ok: true };
|
|
418
413
|
} catch (err) {
|
|
419
|
-
store.update(
|
|
420
|
-
store.appendLog(
|
|
414
|
+
store.update(ticketId, { analyzing: false });
|
|
415
|
+
store.appendLog(ticketId, { type: "error", text: String(err.message || err) });
|
|
416
|
+
return { ok: false };
|
|
421
417
|
} finally {
|
|
422
|
-
runs.end(
|
|
423
|
-
}
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
app.post("/api/tickets/:id/mode", (req, res) => {
|
|
427
|
-
const { mode } = req.body; // "agent" | "human" | null (inherit board default)
|
|
428
|
-
if (mode !== null && mode !== "agent" && mode !== "human") {
|
|
429
|
-
return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
|
|
418
|
+
runs.end(ticketId);
|
|
430
419
|
}
|
|
431
|
-
|
|
432
|
-
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
433
|
-
res.json(ticket);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
app.post("/api/tickets/:id/agent", (req, res) => {
|
|
437
|
-
const { agentId } = req.body;
|
|
438
|
-
if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
|
|
439
|
-
const ticket = store.update(req.params.id, { agentId: agentId ?? null });
|
|
440
|
-
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
441
|
-
res.json(ticket);
|
|
442
|
-
});
|
|
420
|
+
}
|
|
443
421
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
422
|
+
/**
|
|
423
|
+
* The implementation run, factored out of its route for the same reason. Owns
|
|
424
|
+
* its own run registration, resolves mode/agent, resets the staging state to a
|
|
425
|
+
* clean QA budget, cuts the worktree, then either runs the agent straight into
|
|
426
|
+
* Staging (agent mode) or plans and waits for approval (human mode).
|
|
427
|
+
*/
|
|
428
|
+
async function runImplementation(ticketId) {
|
|
429
|
+
const ticket = store.get(ticketId);
|
|
430
|
+
if (!ticket || runs.isRunning(ticketId)) return;
|
|
448
431
|
|
|
449
432
|
const mode = resolveMode(ticket);
|
|
450
433
|
const agent = resolveAgent(ticket);
|
|
451
434
|
|
|
452
435
|
let signal;
|
|
453
436
|
try {
|
|
454
|
-
signal = runs.begin(
|
|
455
|
-
} catch
|
|
456
|
-
return
|
|
437
|
+
signal = runs.begin(ticketId, mode);
|
|
438
|
+
} catch {
|
|
439
|
+
return;
|
|
457
440
|
}
|
|
458
441
|
|
|
459
|
-
res.json({ started: true, mode, agentId: agent?.id ?? null });
|
|
460
442
|
// A fresh run resets the staging state so a re-run starts with a clean QA
|
|
461
443
|
// budget (the loop-stop guard counts from here).
|
|
462
|
-
store.update(
|
|
444
|
+
store.update(ticketId, {
|
|
463
445
|
status: "in_progress",
|
|
464
446
|
mode,
|
|
465
447
|
agentId: agent?.id ?? ticket.agentId,
|
|
@@ -471,11 +453,11 @@ export function createServer() {
|
|
|
471
453
|
escalated: false,
|
|
472
454
|
});
|
|
473
455
|
|
|
474
|
-
const onEvent = (event) => store.appendLog(
|
|
456
|
+
const onEvent = (event) => store.appendLog(ticketId, { type: "agent_event", event });
|
|
475
457
|
|
|
476
458
|
try {
|
|
477
|
-
const { dir: worktreeDir, branch, base } = await createTicketWorktree(
|
|
478
|
-
store.update(
|
|
459
|
+
const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticketId);
|
|
460
|
+
store.update(ticketId, { worktree: worktreeDir, branch, baseBranch: base });
|
|
479
461
|
|
|
480
462
|
const prompt = buildImplementationPrompt(ticket);
|
|
481
463
|
|
|
@@ -483,17 +465,17 @@ export function createServer() {
|
|
|
483
465
|
// Full autonomy, single call.
|
|
484
466
|
const result = await runAgent({ prompt, cwd: worktreeDir, onEvent, signal, agent });
|
|
485
467
|
if (result.aborted) return; // cancel route already marked it
|
|
486
|
-
const diff = await diffWorktree(
|
|
487
|
-
store.update(
|
|
468
|
+
const diff = await diffWorktree(ticketId).catch(() => null);
|
|
469
|
+
store.update(ticketId, { status: "staging", diff, sessionId: result.sessionId, awaitingApproval: false });
|
|
488
470
|
// Validate in Staging before anything ships — the QA gate stands between
|
|
489
471
|
// a finished run and the PR.
|
|
490
|
-
await enterStaging(
|
|
472
|
+
await enterStaging(ticketId, { signal, onEvent, mode });
|
|
491
473
|
} else {
|
|
492
474
|
// Human-in-the-loop: plan only, no writes yet. The card shows the
|
|
493
475
|
// plan and waits for an explicit Approve before phase 2 runs.
|
|
494
476
|
const result = await planTicket({ prompt, cwd: worktreeDir, onEvent, signal, agent });
|
|
495
477
|
if (result.aborted) return;
|
|
496
|
-
store.update(
|
|
478
|
+
store.update(ticketId, {
|
|
497
479
|
status: "staging",
|
|
498
480
|
sessionId: result.sessionId,
|
|
499
481
|
plan: result.lastMessage,
|
|
@@ -501,14 +483,77 @@ export function createServer() {
|
|
|
501
483
|
});
|
|
502
484
|
}
|
|
503
485
|
} catch (err) {
|
|
504
|
-
store.appendLog(
|
|
505
|
-
store.cancel(
|
|
506
|
-
appendRunLog(store.get(
|
|
486
|
+
store.appendLog(ticketId, { type: "error", text: String(err.message || err) });
|
|
487
|
+
store.cancel(ticketId, `Run failed: ${err.message || err}`);
|
|
488
|
+
appendRunLog(store.get(ticketId), `failed: ${String(err.message || err).split("\n")[0].slice(0, 60)}`);
|
|
507
489
|
} finally {
|
|
508
|
-
runs.end(
|
|
490
|
+
runs.end(ticketId);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Agent loop is hands-off from intake onward: a freshly created agent-mode
|
|
495
|
+
// ticket analyzes then runs on its own — the Telegram interview is the only
|
|
496
|
+
// human touch until the PR. A failed/cancelled analyze leaves it in Inbox
|
|
497
|
+
// rather than running blind. Human mode leaves it in Inbox for the buttons.
|
|
498
|
+
async function autoPipeline(ticketId) {
|
|
499
|
+
const analysis = await runAnalyze(ticketId);
|
|
500
|
+
if (!analysis?.ok) return;
|
|
501
|
+
if (store.get(ticketId)?.status !== "analyzed") return;
|
|
502
|
+
await runImplementation(ticketId);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// --- tickets ---
|
|
506
|
+
|
|
507
|
+
app.get("/api/tickets", (_req, res) => {
|
|
508
|
+
res.json(store.list());
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
app.post("/api/tickets", (req, res) => {
|
|
512
|
+
const { title, body, source, agentId, mode, priority, summary } = req.body;
|
|
513
|
+
if (!title?.trim()) return res.status(400).json({ error: "title is required" });
|
|
514
|
+
const ticket = store.create({ title: title.trim(), body: body ?? "", source, agentId, mode, priority, summary });
|
|
515
|
+
res.json(ticket);
|
|
516
|
+
if (resolveMode(ticket) === "agent") {
|
|
517
|
+
autoPipeline(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: `auto-pipeline: ${String(err.message || err)}` }));
|
|
509
518
|
}
|
|
510
519
|
});
|
|
511
520
|
|
|
521
|
+
app.post("/api/tickets/:id/analyze", (req, res) => {
|
|
522
|
+
const ticket = store.get(req.params.id);
|
|
523
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
524
|
+
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
|
|
525
|
+
res.json({ started: true });
|
|
526
|
+
runAnalyze(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: String(err.message || err) }));
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
app.post("/api/tickets/:id/mode", (req, res) => {
|
|
530
|
+
const { mode } = req.body; // "agent" | "human" | null (inherit board default)
|
|
531
|
+
if (mode !== null && mode !== "agent" && mode !== "human") {
|
|
532
|
+
return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
|
|
533
|
+
}
|
|
534
|
+
const ticket = store.update(req.params.id, { mode });
|
|
535
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
536
|
+
res.json(ticket);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
app.post("/api/tickets/:id/agent", (req, res) => {
|
|
540
|
+
const { agentId } = req.body;
|
|
541
|
+
if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
|
|
542
|
+
const ticket = store.update(req.params.id, { agentId: agentId ?? null });
|
|
543
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
544
|
+
res.json(ticket);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
app.post("/api/tickets/:id/run", (req, res) => {
|
|
548
|
+
const ticket = store.get(req.params.id);
|
|
549
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
550
|
+
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
|
|
551
|
+
const mode = resolveMode(ticket);
|
|
552
|
+
const agent = resolveAgent(ticket);
|
|
553
|
+
res.json({ started: true, mode, agentId: agent?.id ?? null });
|
|
554
|
+
runImplementation(ticket.id).catch((err) => store.appendLog(ticket.id, { type: "error", text: String(err.message || err) }));
|
|
555
|
+
});
|
|
556
|
+
|
|
512
557
|
app.post("/api/tickets/:id/approve", async (req, res) => {
|
|
513
558
|
const ticket = store.get(req.params.id);
|
|
514
559
|
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
@@ -693,7 +738,7 @@ export function createServer() {
|
|
|
693
738
|
// ouro already holding the port. Without it, a probe that gets a 200 only
|
|
694
739
|
// proves someone is listening — not that the process we just spawned is
|
|
695
740
|
// the one answering.
|
|
696
|
-
res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), pid: process.pid, config });
|
|
741
|
+
res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), maxQaAttempts: getMaxQaAttempts(), pid: process.pid, config });
|
|
697
742
|
});
|
|
698
743
|
|
|
699
744
|
app.post("/api/config/backend", (req, res) => {
|