@galda/cli 0.10.19 → 0.10.20

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 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,13 @@
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のリロード連打)を引き起こす。**5回繰り返した実績のあるインシデントクラス**(`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
+ - 「無限ループが起きてる」と報告されたら、コードを読みに行く前に**必ず**`docs/RUNBOOK-relay-mutual-kick.md`の診断手順(`ps aux | grep relay-client`から)に従う。
70
83
 
71
84
  ## 構成
72
85
 
package/engine/lib.mjs CHANGED
@@ -3112,3 +3112,25 @@ 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
+ }
@@ -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
- const PORT = Number(process.env.MANAGER_PORT ?? 4400);
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.19",
3
+ "version": "0.10.20",
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=2 'engine/test/*.test.mjs'"
8
+ "test": "node --test --test-concurrency=1 'engine/test/*.test.mjs'"
9
9
  },
10
10
  "dependencies": {
11
11
  "puppeteer-core": "^23.11.1",