@galda/cli 0.10.19 → 0.10.21
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/CLAUDE.md +17 -3
- package/app/index.html +24 -1
- package/engine/lib.mjs +38 -0
- package/engine/relay-client.mjs +37 -1
- package/engine/server.mjs +6 -2
- package/package.json +2 -2
package/CLAUDE.md
CHANGED
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
- `毎回、実装したものにはテストを書いて、実行する`。
|
|
8
8
|
- engine/ の変更 → `node --test --test-concurrency=1 'engine/test/*.test.mjs'` が緑になるまで完了と言わない。
|
|
9
9
|
- 🔴 **`--test-concurrency=1` は必須**(Masa報告のカオス調査で発見・2026-07-16)。既定(CPUコア数)だと
|
|
10
|
-
58
|
|
11
|
-
(実測: 1回目=2件赤 → 2回目=別の4件赤。`multi-user-server` はサーバが起動せず18msで全滅)。
|
|
12
|
-
直列なら **527 pass / 1 fail / 1 skip** で安定し、所要時間もほぼ同じ(約102秒)。
|
|
10
|
+
58ファイルが同時に実サーバと Chrome を立てて**マシンを飽和させ、同じコードで赤が毎回入れ替わる**。
|
|
13
11
|
**「テスト緑」がコードの状態でなく運を報告している状態を放置しない**=規律の土台。
|
|
12
|
+
- **原因の半分は 2026-07-17 に構造で潰した**(PR: test-port-allocation)。各テストが
|
|
13
|
+
`4600 + (pid % 40)` 式で**ポートを手書きし隣接バンドで衝突**していた分は、`MANAGER_PORT=0`+
|
|
14
|
+
`engine/test/helpers/start-server.mjs` に一本化して**原理的に消えた**(OSは同じポートを2つに渡さない)。
|
|
15
|
+
`multi-user-server` の「18msで全滅」はこれ。**テストで新しくポートを書かない**=ヘルパを使う。
|
|
16
|
+
- **残る半分は資源の飽和**(実サーバ+Chrome の総量)。実測 2026-07-17: 並列だと1回目3件赤→2回目2件赤で
|
|
17
|
+
**まだ揺れる上に遅い**(並列152秒 vs 直列135秒)。**並列に戻す根拠は無い**=直列のまま。
|
|
18
|
+
直列は2回連続で **584 pass / 既知の赤1件** と完全一致。
|
|
19
|
+
次に上限を上げたければ、テストが実サーバを立てる設計自体(in-process 化等)を変えること。
|
|
14
20
|
- 既知の赤1件=`persist-failure`(full-disk resilience)。**素の `origin/main` でも落ちる**(`fix/persist-log-disk-full` が未マージ)。
|
|
15
21
|
自分の変更のせいかは、素の main と比べて判定する。
|
|
16
22
|
- 新しいロジックは可能な限り純関数として `engine/lib.mjs` 等に切り出し、`engine/test/` にテストを追加する。
|
|
@@ -67,6 +73,14 @@
|
|
|
67
73
|
- **`a2c-tech.workers.dev` を絶対に使わない・コードやdocsに書かない**。これはクライアント企業 **A2C** の名を冠した Cloudflare workers.dev サブドメインで、Galda の製品・URL・設定に一切登場させてはいけない(見つけたら全て除去)。
|
|
68
74
|
- Worker/API へは**必ず所有ドメイン `https://galda.app`(custom domain)経由**で参照する。`*.workers.dev` サブドメインを製品コードに焼き込まない(`MANAGER_BILLING_API_URL` 等の既定値は `https://galda.app`)。
|
|
69
75
|
- **Cloudflare/wrangler にデプロイする前に `wrangler whoami` でデプロイ先アカウントを確認**し、Galda/Kodo 所有であること・workers.dev サブドメイン名に客先名や別プロジェクト名が出ていないことを確かめる。怪しければ止めて Masa に確認(本番デプロイは Masa 明示許可制)。
|
|
76
|
+
- **リリースは4面**(① npm ② Fly ③ Worker ④ Masaの常駐インスタンス `~/manager-for-ai`:4400)。①だけ出して④を忘れると「直したのに直ってない」+relay相互kickループ再発の実例あり(2026-07-16/17)。全文=`docs/RELEASE-WORKFLOW.md`の「本番は1つじゃない」表。
|
|
77
|
+
|
|
78
|
+
## relay接続(`app.galda.app`)を伴う作業の掟(Masa明言 2026-07-17・全レーン必読)
|
|
79
|
+
|
|
80
|
+
- **relayは1アカウント=同時1接続のみ**。同じ`MANAGER_HOME`(既定`~/.manager-for-ai`)を指す2つ目のプロセスを起動すると、実本番アカウントの接続を奪い合い、"無限ループ"(app.galda.appのリロード連打)を引き起こす。**6回繰り返した実績のあるインシデントクラス**(`docs/RUNBOOK-relay-mutual-kick.md`参照)。
|
|
81
|
+
- `engine/relay-client.mjs`は2026-07-17からPIDロックで2つ目の起動を自動拒否するが、**手動検証で`node engine/relay-client.mjs`や`npx @galda/cli`を叩く時は、必ず隔離した`MANAGER_HOME`を使う**(本番の`~/.manager-for-ai`を素で使わない)。
|
|
82
|
+
- **"無限ループ"に見えても原因は2系統ある**(プロセスが1個だけの時は相互kickではない=**アカウント不一致**。ローカルのライセンスemailとブラウザのGoogleサインインemailが違うと、プロセスは正常なのに永遠に「未接続」に見える)。`docs/RUNBOOK-relay-mutual-kick.md`の「診断の分岐」でプロセス数を数えてから対応を選ぶ。
|
|
83
|
+
- 「無限ループが起きてる」と報告されたら、コードを読みに行く前に**必ず**`docs/RUNBOOK-relay-mutual-kick.md`の診断手順(`ps aux | grep relay-client`から)に従う。
|
|
70
84
|
|
|
71
85
|
## 構成
|
|
72
86
|
|
package/app/index.html
CHANGED
|
@@ -6057,8 +6057,16 @@ function bootDisconnected(info){
|
|
|
6057
6057
|
#connectGate .cg-hint a:hover{text-decoration:underline}
|
|
6058
6058
|
#connectGate .cg-who{font-size:12px;color:var(--ink3,#6b6862);margin:0 0 4px}
|
|
6059
6059
|
#connectGate .cg-who b{color:var(--ink2,#a3a097)}
|
|
6060
|
-
#connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0}
|
|
6060
|
+
#connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0;transition:color .2s}
|
|
6061
6061
|
#connectGate .cg-switch a{color:var(--blue,#8fb0ff);text-decoration:none}
|
|
6062
|
+
/* A long wait (15s+) almost always means an account mismatch, not a slow
|
|
6063
|
+
first connect (Masa 2026-07-17: kept re-running npx, kept landing back
|
|
6064
|
+
here — the always-on caption below was too easy to miss). Escalate to
|
|
6065
|
+
the existing warning tone (--amber, same as failed-step/review chips)
|
|
6066
|
+
— color only, no filled box, per the hover/attention convention used
|
|
6067
|
+
everywhere else in this app. */
|
|
6068
|
+
#connectGate .cg-switch.escalated{color:var(--amber);font-weight:600}
|
|
6069
|
+
#connectGate .cg-switch.escalated a{color:var(--amber);text-decoration:underline}
|
|
6062
6070
|
#connectGate .cg-x{position:absolute;top:14px;right:16px;width:28px;height:28px;border:0;background:transparent;color:var(--ink3,#6b6862);font-size:20px;line-height:1;cursor:pointer}
|
|
6063
6071
|
#connectGate .cg-wait{display:inline-flex;align-items:center;gap:7px;font:500 10.5px/1 var(--mono,monospace);letter-spacing:.08em;color:var(--ink3,#6b6862);margin-top:16px}
|
|
6064
6072
|
#connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
|
|
@@ -6111,11 +6119,26 @@ function bootDisconnected(info){
|
|
|
6111
6119
|
// `setInterval` + `if (status !== 503) reload` poll, which amplified any agent
|
|
6112
6120
|
// flap into an infinite reload (the ~10-failure "無限ループ").
|
|
6113
6121
|
const cgWait = ov.querySelector('.cg-wait');
|
|
6122
|
+
const cgSwitch = ov.querySelector('.cg-switch');
|
|
6123
|
+
// Escalate the account-mismatch hint after a long wait (2026-07-17: mirrors
|
|
6124
|
+
// engine/lib.mjs shouldEscalateConnectGateHint — inlined, this file can't
|
|
6125
|
+
// import engine/lib.mjs). A genuine first connect takes a few seconds; a
|
|
6126
|
+
// wait that crosses 15s is almost always the local agent being signed in
|
|
6127
|
+
// under a DIFFERENT email than this browser (relay identity is per-email,
|
|
6128
|
+
// so re-running `npx @galda/cli` — which reconnects that SAME mismatched
|
|
6129
|
+
// identity — never helps; only `--signin` does). Naming the actual signed-
|
|
6130
|
+
// in email turns "I have no idea what's wrong" into "oh, wrong account".
|
|
6131
|
+
const CG_ESCALATE_MS = 15000;
|
|
6132
|
+
const escalateTimer = (email && cgSwitch) ? setTimeout(() => {
|
|
6133
|
+
cgSwitch.classList.add('escalated');
|
|
6134
|
+
cgSwitch.innerHTML = `Still waiting? Galda may already be running on this computer under a <b>different account</b> than <b>${email.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]))}</b>. Run <code>npx @galda/cli --signin</code> in that terminal and pick this account. Or <a href="/logout">switch this browser →</a>`;
|
|
6135
|
+
}, CG_ESCALATE_MS) : null;
|
|
6114
6136
|
try {
|
|
6115
6137
|
const es = new EventSource('/presence');
|
|
6116
6138
|
es.addEventListener('presence', (e) => {
|
|
6117
6139
|
let d; try { d = JSON.parse(e.data); } catch { return; }
|
|
6118
6140
|
if (d.state === 'connected') {
|
|
6141
|
+
if (escalateTimer) clearTimeout(escalateTimer);
|
|
6119
6142
|
// Confirm the engine is REALLY reachable before reloading. A stale/spurious
|
|
6120
6143
|
// 'connected' presence otherwise reloads us into a white flash that lands
|
|
6121
6144
|
// right back on this same connect screen — the welcome animation plays, then
|
package/engine/lib.mjs
CHANGED
|
@@ -3112,3 +3112,41 @@ export function relayReconnectDecision(state, event) {
|
|
|
3112
3112
|
return { state: s, dial: false, reason: 'noop' };
|
|
3113
3113
|
}
|
|
3114
3114
|
}
|
|
3115
|
+
|
|
3116
|
+
// --- Relay-client single-instance guard (2026-07-17, five incidents deep) -----
|
|
3117
|
+
// Every relay mutual-kick incident so far (#86/#88/#106, and the one that
|
|
3118
|
+
// prompted this) traces back to the SAME root: nothing has ever stopped more
|
|
3119
|
+
// than one relay-client process from authenticating as the same account at
|
|
3120
|
+
// once — a background launchd instance, a fresh `npx @galda/cli`, and an
|
|
3121
|
+
// engineer's ad-hoc `node engine/relay-client.mjs` test run can all share the
|
|
3122
|
+
// same DATA_DIR (and therefore the same license.token) with zero coordination.
|
|
3123
|
+
// relayReconnectDecision() makes each INDIVIDUAL process behave correctly once
|
|
3124
|
+
// evicted; it can't stop a THIRD, FOURTH, FIFTH process from starting fresh —
|
|
3125
|
+
// a fresh start isn't a "reconnect", so the reducer never even sees it. This
|
|
3126
|
+
// closes that gap at the one point it can be closed structurally: refuse to
|
|
3127
|
+
// start a second live process against the same DATA_DIR at all, the same
|
|
3128
|
+
// pattern tools/lane.mjs uses for hot-file locks (fail at start time, not
|
|
3129
|
+
// after the fact). `alive` is passed in (not computed here) so this stays a
|
|
3130
|
+
// pure decision — the caller does the actual process.kill(pid, 0) liveness
|
|
3131
|
+
// check (real I/O, not testable as a pure function).
|
|
3132
|
+
export function shouldRefuseRelayClientStart({ existingLock, alive } = {}) {
|
|
3133
|
+
if (!existingLock || !Number.isFinite(existingLock.pid)) return { refuse: false, reason: 'no-lock' };
|
|
3134
|
+
if (!alive) return { refuse: false, reason: 'stale-lock' };
|
|
3135
|
+
return { refuse: true, reason: 'already-running', holderPid: existingLock.pid };
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// --- Connect-gate account-mismatch escalation (2026-07-17) ------------------
|
|
3139
|
+
// A genuinely fresh "run npx @galda/cli" wait is short — the agent connects
|
|
3140
|
+
// within a few seconds. A wait that runs long almost always means one thing:
|
|
3141
|
+
// the local agent IS running, but it's licensed under a DIFFERENT email than
|
|
3142
|
+
// the one signed in on this browser (relay identity is per-email, so they
|
|
3143
|
+
// never meet). Before this, the only guidance for that exact scenario was a
|
|
3144
|
+
// small always-on caption easy to miss among the first-time-setup copy — a
|
|
3145
|
+
// user just re-runs `npx @galda/cli` (which reconnects the SAME already-
|
|
3146
|
+
// mismatched identity) and waits again, forever. Escalating the hint once the
|
|
3147
|
+
// wait crosses a threshold — with the actual signed-in email substituted in —
|
|
3148
|
+
// turns "I have no idea what's wrong" into "oh, wrong account" (see
|
|
3149
|
+
// docs/RUNBOOK-relay-mutual-kick.md's account-mismatch entry).
|
|
3150
|
+
export function shouldEscalateConnectGateHint(waitedMs, email, thresholdMs = 15000) {
|
|
3151
|
+
return Number(waitedMs) >= thresholdMs && Boolean(String(email ?? '').trim());
|
|
3152
|
+
}
|
package/engine/relay-client.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { readFileSync, writeFileSync, existsSync, watch, unlinkSync } from 'node
|
|
|
12
12
|
import { resolve, dirname, join } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { licenseTokenIdentity, relayReconnectDecision } from './lib.mjs';
|
|
15
|
+
import { licenseTokenIdentity, relayReconnectDecision, shouldRefuseRelayClientStart } from './lib.mjs';
|
|
16
16
|
|
|
17
17
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
18
|
const DATA_DIR = process.env.MANAGER_HOME
|
|
@@ -182,7 +182,43 @@ function handleTakeover() {
|
|
|
182
182
|
if (d.dial) { console.log('[agent] taking over this account on this device (user requested)'); redialNow(); }
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
// Single-instance guard (2026-07-17): refuse to start a second relay-client
|
|
186
|
+
// against the same DATA_DIR. See lib.mjs shouldRefuseRelayClientStart for why —
|
|
187
|
+
// this is the one point that actually stops the "N processes, one account"
|
|
188
|
+
// mutual-kick class of incident, rather than making each process behave
|
|
189
|
+
// better once it's already one of several. RELAY_CLIENT_FORCE=1 overrides for
|
|
190
|
+
// the rare legitimate case (you know two are up and you're taking over by hand).
|
|
191
|
+
const LOCK_FILE = join(DATA_DIR, 'relay-client.lock');
|
|
192
|
+
function readLock() {
|
|
193
|
+
try { return JSON.parse(readFileSync(LOCK_FILE, 'utf8')); } catch { return null; }
|
|
194
|
+
}
|
|
195
|
+
function isPidAlive(pid) {
|
|
196
|
+
if (!Number.isFinite(pid)) return false;
|
|
197
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
198
|
+
}
|
|
199
|
+
function writeLock() {
|
|
200
|
+
try { writeFileSync(LOCK_FILE, JSON.stringify({ pid: process.pid, startedAt: Date.now() })); } catch { /* best-effort */ }
|
|
201
|
+
}
|
|
202
|
+
function releaseLock() {
|
|
203
|
+
try {
|
|
204
|
+
const l = readLock();
|
|
205
|
+
if (l && l.pid === process.pid) unlinkSync(LOCK_FILE);
|
|
206
|
+
} catch { /* already gone */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
if (!process.env.RELAY_CLIENT_NO_START) {
|
|
210
|
+
const existingLock = readLock();
|
|
211
|
+
const guard = shouldRefuseRelayClientStart({ existingLock, alive: existingLock ? isPidAlive(existingLock.pid) : false });
|
|
212
|
+
if (guard.refuse && !process.env.RELAY_CLIENT_FORCE) {
|
|
213
|
+
console.error(`[agent] relay-client: another instance is already running for this account (pid ${guard.holderPid}, ${DATA_DIR}).`);
|
|
214
|
+
console.error('[agent] Running two at once is exactly what causes the relay mutual-kick loop — stop the other one first.');
|
|
215
|
+
console.error('[agent] If you are certain (e.g. taking over by hand), set RELAY_CLIENT_FORCE=1 and re-run.');
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
writeLock();
|
|
219
|
+
process.on('exit', releaseLock);
|
|
220
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { releaseLock(); process.exit(0); });
|
|
221
|
+
|
|
186
222
|
connect();
|
|
187
223
|
|
|
188
224
|
// Watch DATA_DIR for two triggers (the file may not exist yet on first run;
|
package/engine/server.mjs
CHANGED
|
@@ -31,7 +31,10 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
31
31
|
const DATA_DIR = process.env.MANAGER_HOME
|
|
32
32
|
?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(homedir(), '.manager-for-ai'));
|
|
33
33
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
34
|
-
|
|
34
|
+
// MANAGER_PORT=0 asks the OS for a free port; the real one is only known once
|
|
35
|
+
// the listen below succeeds, so this is rebound there (before anything prints
|
|
36
|
+
// or links to it).
|
|
37
|
+
let PORT = Number(process.env.MANAGER_PORT ?? 4400);
|
|
35
38
|
|
|
36
39
|
// Access key: required for every request when the server is exposed beyond
|
|
37
40
|
// localhost (e.g. through a tunnel). Auto-generated once, kept out of git.
|
|
@@ -3958,7 +3961,6 @@ const server = createServer(async (req, res) => {
|
|
|
3958
3961
|
// Same request handler, loopback-only, best-effort (skip if ::1 unavailable).
|
|
3959
3962
|
const server6 = createServer(server.listeners('request')[0]);
|
|
3960
3963
|
server6.on('error', (e) => console.log(`[manager] ::1 listen skipped: ${e.code}`));
|
|
3961
|
-
try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
|
|
3962
3964
|
|
|
3963
3965
|
// Guard the primary IPv4 listen the same way the ::1 twin is guarded: without
|
|
3964
3966
|
// this, a busy port emits an unhandled 'error' and Node prints a raw stack
|
|
@@ -3975,6 +3977,8 @@ server.on('error', (e) => {
|
|
|
3975
3977
|
});
|
|
3976
3978
|
|
|
3977
3979
|
server.listen(PORT, '127.0.0.1', () => {
|
|
3980
|
+
PORT = server.address().port; // the OS-assigned one when MANAGER_PORT=0
|
|
3981
|
+
try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
|
|
3978
3982
|
console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
|
|
3979
3983
|
if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
|
|
3980
3984
|
// Hosted flow (billing worker + named app configured): the user lives in
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@galda/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"start": "node engine/server.mjs",
|
|
8
|
-
"test": "node --test --test-concurrency=
|
|
8
|
+
"test": "node --test --test-concurrency=1 'engine/test/*.test.mjs'"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"puppeteer-core": "^23.11.1",
|