@galda/cli 0.10.34 → 0.10.36
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.
- package/app/index.html +475 -107
- package/bin/manager-for-ai.mjs +12 -2
- package/engine/lib.mjs +119 -1
- package/engine/relay-supervisor.mjs +113 -0
- package/engine/server.mjs +275 -38
- package/engine/verify.mjs +9 -2
- package/package.json +1 -1
package/bin/manager-for-ai.mjs
CHANGED
|
@@ -118,8 +118,18 @@ await import(pathToFileURL(join(ROOT, 'engine', 'server.mjs')).href);
|
|
|
118
118
|
// public build will default it to the deployed relay. See relay/DEPLOY.md.
|
|
119
119
|
if (process.env.RELAY_URL) {
|
|
120
120
|
const { spawn } = await import('node:child_process');
|
|
121
|
-
const
|
|
122
|
-
|
|
121
|
+
const { superviseRelay } = await import(pathToFileURL(join(ROOT, 'engine', 'relay-supervisor.mjs')).href);
|
|
122
|
+
// Supervise the relay-client: if the CHILD exits (uncaught throw, single-
|
|
123
|
+
// instance lock refuse) we bring it back with backoff, otherwise the app goes
|
|
124
|
+
// silently offline ("Unsent"). A tight crash loop (a config/lock error) trips a
|
|
125
|
+
// circuit breaker so we stop hammering and say why. relay-client heals its own
|
|
126
|
+
// dropped socket; this heals the whole process dying. See engine/relay-supervisor.mjs.
|
|
127
|
+
const sup = superviseRelay({
|
|
128
|
+
spawn: () => spawn(process.execPath, [join(ROOT, 'engine', 'relay-client.mjs')], { stdio: 'inherit', env: process.env }),
|
|
129
|
+
log: ({ code, signal, delayMs }) => say(`relay-client exited (code ${code ?? 'n/a'}${signal ? `, ${signal}` : ''}) — restarting in ${Math.round(delayMs / 1000)}s`),
|
|
130
|
+
onGiveUp: () => say('relay-client keeps exiting immediately — giving up; run `npx @galda/cli --signin` or check RELAY_URL'),
|
|
131
|
+
});
|
|
132
|
+
const stop = () => sup.stop();
|
|
123
133
|
process.on('exit', stop);
|
|
124
134
|
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stop(); process.exit(0); });
|
|
125
135
|
say(`relay: connecting to ${process.env.RELAY_URL} — your fixed URL goes live once you sign in`);
|
package/engine/lib.mjs
CHANGED
|
@@ -906,6 +906,73 @@ export function hasTestRelevantChanges(changedFiles) {
|
|
|
906
906
|
return files.some((f) => !DOC_ONLY_RE.test(f));
|
|
907
907
|
}
|
|
908
908
|
|
|
909
|
+
// Extract the set of FAILING test titles from a `node --test` (or TAP) run.
|
|
910
|
+
// The gate needs NAMES, not just a count, so it can tell a NEW failure (this
|
|
911
|
+
// change's fault) from one that was ALREADY red on the base (e.g. a known-red
|
|
912
|
+
// like persist-failure that fails even on clean origin/main — CLAUDE.md).
|
|
913
|
+
// node --test prints `✖ <title> (1234.5ms)` (both inline and again under the
|
|
914
|
+
// `✖ failing tests:` summary header, which carries no title and is skipped);
|
|
915
|
+
// TAP prints `not ok N - <title>` (a `# SKIP`/`# TODO` directive is not a
|
|
916
|
+
// failure). Titles are de-duplicated and sorted for a stable set-diff.
|
|
917
|
+
export function parseFailingTestNames(text) {
|
|
918
|
+
const out = new Set();
|
|
919
|
+
const stripMs = (s) => s.replace(/\s*\(\d[\d.]*m?s\)\s*$/i, '').trim();
|
|
920
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
921
|
+
const line = raw.trim();
|
|
922
|
+
if (!line) continue;
|
|
923
|
+
let m = line.match(/^[✖✗✘×]\s+(.*)$/);
|
|
924
|
+
if (m) {
|
|
925
|
+
const title = stripMs(m[1].trim());
|
|
926
|
+
if (!title || /^failing tests:?$/i.test(title)) continue; // summary header
|
|
927
|
+
out.add(title);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
m = line.match(/^not ok\s+\d+\s*-?\s*(.*)$/i);
|
|
931
|
+
if (m) {
|
|
932
|
+
if (/#\s*(SKIP|TODO)\b/i.test(line)) continue; // directive, not a real failure
|
|
933
|
+
const title = stripMs(m[1].replace(/\s+#.*$/, '').trim());
|
|
934
|
+
if (title) out.add(title);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return [...out].sort();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Compare the CURRENT run's failing tests against the BASE (pre-change) run's
|
|
941
|
+
// so the review gate blocks ONLY on failures this change introduced. A test
|
|
942
|
+
// that was already red on the base is pre-existing noise (unrelated to the
|
|
943
|
+
// goal) — it must not knock a proof-verified goal back to blocked/Failed.
|
|
944
|
+
// Callers should treat an EMPTY baseline as "unknown" and fall back to the old
|
|
945
|
+
// block-on-any-red behaviour (fail closed) — see verifyGate.
|
|
946
|
+
export function classifyTestGate({ current = [], baseline = [] } = {}) {
|
|
947
|
+
const base = new Set(baseline);
|
|
948
|
+
const cur = [...new Set((current ?? []).filter(Boolean))];
|
|
949
|
+
const newFailures = cur.filter((t) => !base.has(t)).sort();
|
|
950
|
+
const preExisting = cur.filter((t) => base.has(t)).sort();
|
|
951
|
+
return { newFailures, preExisting, blocked: newFailures.length > 0 };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// The verification-gate decision (Masa 2026-07-19: "検証/proof は Failed を作らない").
|
|
955
|
+
// PROOF IS ADVISORY: a UI goal whose screenshot could not be captured
|
|
956
|
+
// (proofMissing) or whose pixel heuristic could not auto-confirm the target
|
|
957
|
+
// (proofFailing) is NOT blocked — the work is done and reaches Review with a
|
|
958
|
+
// note; proof adds confidence, it never subtracts a correct result. What still
|
|
959
|
+
// blocks a goal from Review: newly-failing tests (testsFailing — the caller has
|
|
960
|
+
// already made this baseline-aware via classifyTestGate), a promised-but-undone
|
|
961
|
+
// task (nothingVerifiable = a passCondition with no file change and no proof),
|
|
962
|
+
// and the opt-in requireVerifyPass gate (verifyFail, off by default). Pure so
|
|
963
|
+
// the whole gate decision is unit-tested rather than living only inside the
|
|
964
|
+
// server's big async verifyGate.
|
|
965
|
+
export function classifyVerifyGate({
|
|
966
|
+
testsFailing = false, proofMissing = false, proofFailing = false,
|
|
967
|
+
nothingVerifiable = false, verifyFail = false,
|
|
968
|
+
} = {}) {
|
|
969
|
+
const blocked = Boolean(testsFailing || nothingVerifiable || verifyFail);
|
|
970
|
+
const kind = testsFailing ? 'test' : nothingVerifiable ? 'noop' : verifyFail ? 'verify' : null;
|
|
971
|
+
// Advisory note surfaced on the review even when the goal is NOT blocked.
|
|
972
|
+
const proofAdvisory = proofMissing ? 'missing' : proofFailing ? 'unconfirmed' : null;
|
|
973
|
+
return { blocked, kind, proofAdvisory };
|
|
974
|
+
}
|
|
975
|
+
|
|
909
976
|
// Whether to ALSO capture a BASELINE ("before") shot for a before/after
|
|
910
977
|
// comparison. Only worth attempting when the baseline is a genuinely
|
|
911
978
|
// different checkout of the code than the one just captured as "after" — a
|
|
@@ -1308,6 +1375,22 @@ export function classifyComposerIntentHeuristic(text) {
|
|
|
1308
1375
|
return 'unsure'; // both or neither → let the LLM decide
|
|
1309
1376
|
}
|
|
1310
1377
|
|
|
1378
|
+
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). A DETERMINISTIC,
|
|
1379
|
+
// conservative match — no LLM, no cost — that only fires when the WHOLE message
|
|
1380
|
+
// is a short, command-like pause/resume order. Anchored ^…$ with only polite
|
|
1381
|
+
// suffixes allowed after the keyword, plus a length cap, so a described request
|
|
1382
|
+
// that merely contains the word ("止めてほしいバグを直して", "start building the
|
|
1383
|
+
// login") is never mistaken for a control command. Returns 'pause'|'resume'|null.
|
|
1384
|
+
const PAUSE_INTENT_RE = /^(一時停止|いったん停止|一旦停止|停止|止めて|とめて|ストップ|作業を?止めて|全部止めて|全部停止|pause|stop)(?:\s*(して|してください|ください|でお願いします?|お願いします?|please|now))?[\s。.!!、,]*$/i;
|
|
1385
|
+
const RESUME_INTENT_RE = /^(再開|再生|再スタート|続けて|続きから|続行|resume|restart|start|play|go)(?:\s*(して|してください|ください|お願いします?|please|now))?[\s。.!!、,]*$/i;
|
|
1386
|
+
export function detectPauseIntent(text) {
|
|
1387
|
+
const t = (text || '').trim();
|
|
1388
|
+
if (!t || t.length > 24) return null; // a control command is short; long text is a real request
|
|
1389
|
+
if (PAUSE_INTENT_RE.test(t)) return 'pause';
|
|
1390
|
+
if (RESUME_INTENT_RE.test(t)) return 'resume';
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1311
1394
|
// The one prompt that BOTH classifies and (for chat) writes the reply in the
|
|
1312
1395
|
// requester's language — one Haiku round-trip, not two. The model returns a
|
|
1313
1396
|
// single line of JSON: {"intent":"chat|task","reply":"…"}.
|
|
@@ -1907,7 +1990,42 @@ export const WORKFLOW_BUCKETS = ['todo', 'doing', 'review', 'done'];
|
|
|
1907
1990
|
// Tools a `claude -p` worker may call (--allowedTools). TodoWrite must stay
|
|
1908
1991
|
// in this list — it's how the UI's To-do section (sendTodos / SSE 'todos')
|
|
1909
1992
|
// gets any data at all; without it workers can never emit {kind:'todos'}.
|
|
1910
|
-
|
|
1993
|
+
//
|
|
1994
|
+
// Bash was locked to node/npm/npx, which produced the "works in Claude Code,
|
|
1995
|
+
// fails here" class: a goal that legitimately needs python, a build tool, or
|
|
1996
|
+
// even a read-only `git log` would just fail — an error a plain Claude Code
|
|
1997
|
+
// session never hits. Widened here to the common legitimate commands.
|
|
1998
|
+
//
|
|
1999
|
+
// Honest note on what this list does and does NOT buy: it is friction control,
|
|
2000
|
+
// not a security boundary. Bash(node:*) already permits arbitrary execution
|
|
2001
|
+
// (`node -e "..."` can touch the network, delete files, anything), so the old
|
|
2002
|
+
// narrow list contained nothing — it only blocked honest direct commands.
|
|
2003
|
+
// Real isolation would need an OS sandbox (codex runs with --sandbox; the
|
|
2004
|
+
// claude-code worker does not). Given that, the exclusions below are posture,
|
|
2005
|
+
// not a wall:
|
|
2006
|
+
// - git WRITE ops (commit/push/reset/checkout) are left off the fast path so
|
|
2007
|
+
// the worker does not fight the Manager, which owns git in the worktree
|
|
2008
|
+
// (the worker prompt already says "do not commit"). Read-only git is on.
|
|
2009
|
+
// - rm / curl / wget stay off the fast path. (They are reachable via node
|
|
2010
|
+
// anyway; keeping them non-first-class is a stated-posture choice. Whether
|
|
2011
|
+
// to simplify to a blanket `Bash` — technically equivalent risk — is a
|
|
2012
|
+
// customer-safety decision for Masa, not a silent flip here.)
|
|
2013
|
+
const WORKER_BASH = [
|
|
2014
|
+
// JS/TS runtimes and package managers
|
|
2015
|
+
'node', 'npm', 'npx', 'pnpm', 'yarn', 'bun', 'deno',
|
|
2016
|
+
// other languages a task might actually need
|
|
2017
|
+
'python', 'python3', 'pip', 'pip3', 'pytest', 'go', 'cargo', 'rustc',
|
|
2018
|
+
'ruby', 'bundle', 'make',
|
|
2019
|
+
// build/test/lint runners invoked directly
|
|
2020
|
+
'tsc', 'jest', 'vitest', 'eslint', 'prettier',
|
|
2021
|
+
// read-only git — inspect history/diffs like you would locally
|
|
2022
|
+
'git status', 'git log', 'git diff', 'git show', 'git branch',
|
|
2023
|
+
'git blame', 'git ls-files', 'git rev-parse',
|
|
2024
|
+
// read/inspect shell utilities (file EDITS still go through Edit/Write)
|
|
2025
|
+
'cat', 'ls', 'head', 'tail', 'wc', 'find', 'grep', 'rg',
|
|
2026
|
+
'sed', 'awk', 'sort', 'uniq', 'diff', 'echo', 'which', 'env', 'pwd', 'date',
|
|
2027
|
+
].map((c) => `Bash(${c}:*)`).join(',');
|
|
2028
|
+
export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,${WORKER_BASH}`;
|
|
1911
2029
|
|
|
1912
2030
|
// ---- SKILL picker ----------------------------------------------------------
|
|
1913
2031
|
// Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Launcher-side supervisor for the relay-client CHILD process.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists (2026-07-18 incident): the launcher (bin/manager-for-ai.mjs)
|
|
4
|
+
// spawns engine/relay-client.mjs exactly ONCE and only kills it when the parent
|
|
5
|
+
// goes down — there was no path the other way (child dies -> bring it back).
|
|
6
|
+
// relay-client already self-heals a dropped SOCKET (backoff reconnect, capped at
|
|
7
|
+
// 15s), but when the whole PROCESS exits — an uncaught throw, or the single-
|
|
8
|
+
// instance lock refusing to start — nothing respawns it, so the user's app
|
|
9
|
+
// silently goes offline and every new goal shows "Unsent – tap to resend". This
|
|
10
|
+
// is the customer-facing half of the fix (the operator's own box is now kept up
|
|
11
|
+
// by a launchd KeepAlive job; npx users have no launchd, only this launcher).
|
|
12
|
+
//
|
|
13
|
+
// Split into a PURE policy reducer (relayRespawnDecision) and an impure driver
|
|
14
|
+
// (superviseRelay) that owns spawn()/timers/clock — same shape as
|
|
15
|
+
// relayReconnectDecision in engine/lib.mjs. The policy is unit-tested without any
|
|
16
|
+
// real process; the driver is integration-tested with a fake child.
|
|
17
|
+
|
|
18
|
+
// Fresh supervisor state. `delayMs` is the last backoff used; `quickExits` counts
|
|
19
|
+
// consecutive exits that happened inside the "quick-crash" window; `gaveUp` latches
|
|
20
|
+
// once the circuit breaker trips.
|
|
21
|
+
export const relaySupervisorInit = { delayMs: 0, quickExits: 0, gaveUp: false };
|
|
22
|
+
|
|
23
|
+
// Decide what to do when the supervised child exits. Pure — no clock, no I/O.
|
|
24
|
+
//
|
|
25
|
+
// state : previous supervisor state (start from relaySupervisorInit)
|
|
26
|
+
// event : { uptimeMs } — how long the child that just exited had been alive
|
|
27
|
+
// opts : tunables (defaults match the relay socket backoff feel)
|
|
28
|
+
// minMs floor delay for the first / a reset respawn (1s)
|
|
29
|
+
// maxMs ceiling the exponential backoff saturates at (15s)
|
|
30
|
+
// quickMs a child that lived less than this "crash-looped" (10s)
|
|
31
|
+
// limit consecutive quick exits before we give up (5)
|
|
32
|
+
//
|
|
33
|
+
// Returns { restart, delayMs, gaveUp, state }.
|
|
34
|
+
// - A child that lived past `quickMs` is treated as having made progress: the
|
|
35
|
+
// backoff and the quick-exit counter both reset (a healthy long run should not
|
|
36
|
+
// inherit a punishing delay from an earlier bad patch).
|
|
37
|
+
// - Consecutive quick exits escalate the delay (min, 2x, 4x, ... capped at max)
|
|
38
|
+
// and, once they reach `limit`, trip the circuit breaker so we stop hammering a
|
|
39
|
+
// config/lock error forever. The caller logs why and stands down.
|
|
40
|
+
export function relayRespawnDecision(state, event, opts = {}) {
|
|
41
|
+
const { minMs = 1000, maxMs = 15000, quickMs = 10000, limit = 5 } = opts;
|
|
42
|
+
const prev = Number(state?.delayMs);
|
|
43
|
+
const lastDelay = Number.isFinite(prev) ? prev : 0;
|
|
44
|
+
const uptimeMs = Number(event?.uptimeMs) || 0;
|
|
45
|
+
const quick = uptimeMs < quickMs;
|
|
46
|
+
const quickExits = quick ? (Number(state?.quickExits) || 0) + 1 : 0;
|
|
47
|
+
|
|
48
|
+
if (quickExits >= limit) {
|
|
49
|
+
return { restart: false, delayMs: 0, gaveUp: true, state: { delayMs: lastDelay, quickExits, gaveUp: true } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const delayMs = quick
|
|
53
|
+
? (lastDelay < minMs ? minMs : Math.min(lastDelay * 2, maxMs))
|
|
54
|
+
: minMs;
|
|
55
|
+
return { restart: true, delayMs, gaveUp: false, state: { delayMs, quickExits, gaveUp: false } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Drive a respawning child. Impure: owns spawn()/timers/clock, all injectable so
|
|
59
|
+
// the loop is testable with a fake child and fast delays.
|
|
60
|
+
//
|
|
61
|
+
// spawn () => ChildProcess-like (must emit 'exit' and have .kill())
|
|
62
|
+
// now () => ms clock (default Date.now)
|
|
63
|
+
// setTimer/clearTimer timer fns (default global setTimeout/clearTimeout)
|
|
64
|
+
// log ({ code, signal, delayMs }) => void — a respawn was scheduled
|
|
65
|
+
// onGiveUp () => void — circuit breaker tripped
|
|
66
|
+
// opts passed through to relayRespawnDecision
|
|
67
|
+
//
|
|
68
|
+
// Returns { stop, child, state } — stop() suppresses further respawns (normal
|
|
69
|
+
// shutdown) and kills the current child.
|
|
70
|
+
export function superviseRelay({
|
|
71
|
+
spawn,
|
|
72
|
+
now = () => Date.now(),
|
|
73
|
+
setTimer = setTimeout,
|
|
74
|
+
clearTimer = clearTimeout,
|
|
75
|
+
log = () => {},
|
|
76
|
+
onGiveUp = () => {},
|
|
77
|
+
opts,
|
|
78
|
+
} = {}) {
|
|
79
|
+
let shuttingDown = false;
|
|
80
|
+
let state = { ...relaySupervisorInit };
|
|
81
|
+
let child = null;
|
|
82
|
+
let startedAt = 0;
|
|
83
|
+
let timer = null;
|
|
84
|
+
|
|
85
|
+
const start = () => {
|
|
86
|
+
startedAt = now();
|
|
87
|
+
child = spawn();
|
|
88
|
+
child.on('exit', (code, signal) => {
|
|
89
|
+
child = null;
|
|
90
|
+
if (shuttingDown) return; // parent asked us to stop — don't fight it
|
|
91
|
+
const uptimeMs = now() - startedAt;
|
|
92
|
+
const decision = relayRespawnDecision(state, { uptimeMs }, opts);
|
|
93
|
+
state = decision.state;
|
|
94
|
+
if (!decision.restart) { onGiveUp(); return; }
|
|
95
|
+
log({ code, signal, delayMs: decision.delayMs });
|
|
96
|
+
timer = setTimer(start, decision.delayMs);
|
|
97
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const stop = () => {
|
|
102
|
+
shuttingDown = true;
|
|
103
|
+
if (timer) { clearTimer(timer); timer = null; }
|
|
104
|
+
try { child?.kill(); } catch { /* already gone */ }
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
start();
|
|
108
|
+
return {
|
|
109
|
+
stop,
|
|
110
|
+
get child() { return child; },
|
|
111
|
+
get state() { return state; },
|
|
112
|
+
};
|
|
113
|
+
}
|