peertable 0.3.4 → 0.3.5

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.
@@ -1,43 +1,38 @@
1
1
  #!/usr/bin/env node
2
- // Lattice の managed run と円卓の席をつなぐ配車ブリッジ。**AI ではない常駐**で、席へ1バイトも
3
- // 送らない(room 経由で配車する)。
2
+ // Lattice の pull run を円卓へ可視化する常駐。**AI ではない**し、**席へ1バイトも送らない**。
4
3
  //
5
- // usage: run-bridge.mjs <project_dir> <spool_dir> <seat> [seat...] 起動(前面。nohup で常駐させる)
6
- // run-bridge.mjs <project_dir> --stop 停止
4
+ // usage: run-bridge.mjs <project_dir> [--lattice <path>] 起動(前面。nohup で常駐させる)
5
+ // run-bridge.mjs <project_dir> --stop 停止
7
6
  //
8
- // 役割は4つだけ:
9
- // 1. `<spool>/orders/<packet_digest>.json`(Lattice が書く)を見張る
10
- // 2. 空いている席を選び、その席宛に work order を room へ投稿する(`[配車]`)
11
- // 3. 席の `[受諾]` / `[辞退]` / `[完了]` を room の SSE で拾う
12
- // 4. `<spool>/reports/<packet_digest>.json`(bridge が書く)へ state を書く
7
+ // **この常駐は仕事を配らない。** 2026-08-09 のオーナー裁定(改・裁定1)で、装置が席を選んで
8
+ // 仕事を配る向きは撤回された——**作業を選ぶのも始めるのも AI** であり、Lattice がやるのは
9
+ // 着手済み ToDo 間の競合判定と介入だけである。旧版が持っていた席選定・`[配車]` 投稿・
10
+ // `[受諾]`/`[辞退]` の再配車・work report の書き込みは、その向きの部品なので**全部落とした**。
13
11
  //
14
- // **席は spool に触れない。** 席が読むのは room、書くのは worktree だけである(席の作法の正本は
15
- // templates/member.md の「配車で来た仕事」節)。report を書けるのは bridge だけで、Lattice
16
- // report を「状態遷移の合図」としてしか使わない——diff は Lattice が worktree から独立に撮る。
12
+ // 残った役目は2つだけで、**どちらも読み取りである**:
13
+ // 1. pull run の進行(`lattice run observe`)を、変化した時だけ room へ1行返す
14
+ // 2. 装置が出した介入(hold)を、その作業を始めた席宛に room へ返す
17
15
  //
18
- // **worker_pid は受諾した席の process group leader である。** tmux `pane_pid` は pane の shell で、
19
- // 席本体とは別 process group に居る(実測: Claude 席 `claude`(pid=pgid) の親は `-zsh` で別 pgid、
20
- // Codex 席も `node …/codex`(pid=pgid) が leader)。shell の pid を渡すと Lattice の直接 OS 観測が
21
- // `worker process groupを無関係processと共有している` で正しく落ちる。
16
+ // **この常駐は必須経路ではない。** 席は自分で `run intake` / `attach` / `accept` を打ち、
17
+ // 介入は `run intake intervention` で自分でも読めるので、bridge が落ちていても作業は進む——
18
+ // 見えなくなるだけである(peertable 決定63・相互独立)。
19
+ // 書き込む先は room だけで、spool にも run store にも1バイトも書かない。
22
20
  //
23
21
  // 生死の作法は Lattice ADR 0157 に倣う: 自分の pid を記録に置き、起動時に前の記録を掃除し、
24
22
  // 止まらなければ黙って諦めず typed error で落ちる。
25
23
  import { execFile } from 'node:child_process'
26
- import { createHash } from 'node:crypto'
27
24
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
28
- import { open, readdir, readFile, rename, writeFile } from 'node:fs/promises'
29
- import { join, posix, sep } from 'node:path'
25
+ import { join } from 'node:path'
30
26
  import { promisify } from 'node:util'
31
27
 
32
28
  const run = promisify(execFile)
33
29
  const [proj, ...rest] = process.argv.slice(2)
34
- if (!proj || rest.length === 0) {
35
- console.error('usage: run-bridge.mjs <project_dir> <spool_dir> <seat> [seat...] | <project_dir> --stop')
30
+ if (!proj) {
31
+ console.error('usage: run-bridge.mjs <project_dir> [--lattice <path>] | <project_dir> --stop')
36
32
  process.exit(1)
37
33
  }
38
34
 
39
35
  const record = join(proj, '.team', 'run-bridge.json')
40
- const sock = process.env.PEERTABLE_TMUX_SOCKET ?? `${process.env.TMPDIR}claude-tmux-sockets/claude.sock`
41
36
  const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
42
37
  const sleep = ms => new Promise(r => setTimeout(r, ms))
43
38
  const log = line => console.log(`[${new Date().toISOString()}] ${line}`)
@@ -94,34 +89,41 @@ async function stopRecorded({ strict = false } = {}) {
94
89
  log(`前のブリッジを停止した(pid ${pid})`)
95
90
  }
96
91
 
97
- await stopRecorded({ strict: rest[0] === '--stop' })
98
- if (rest[0] === '--stop') process.exit(0)
99
-
100
- // `--lattice <path>` は任意。既定は PATH 上の `lattice`。**release 前の source tree を実測する時**は
101
- // ここで実物を指す(PATH の install は古い版で、新しい run store を `INVALID_RUN_STORE` と誤判定しうる)。
102
- const latticeFlag = rest.indexOf('--lattice')
92
+ // **引数の検査は前のブリッジを止めるより先にやる。** 逆順にすると、旧版の command line
93
+ // (spool dir と席名つき)で叩いた人が「動いているブリッジを殺してから起動に失敗する」——
94
+ // 正典が旧形式を載せていた期間があるので、これは実際に起きる(自分で踏んで気づいた)。
95
+ // `--stop` はここを通さない。止めるだけの呼び出しに `--lattice` は要らない。
103
96
  let latticeCli = 'lattice'
104
- if (latticeFlag >= 0) {
105
- latticeCli = rest[latticeFlag + 1] ?? ''
106
- if (latticeCli.length === 0) {
107
- console.error('RUN_BRIDGE_ARGS_INVALID: --lattice には実行可能な path を渡すこと')
97
+ if (rest[0] !== '--stop') {
98
+ // `--lattice <path>` は任意。既定は PATH 上の `lattice`。**release 前の source tree を実測する時**は
99
+ // ここで実物を指す。PATH install は publish 済みの版なので、**version 表示が同じでも**
100
+ // publish schema を読めず `INVALID_RUN_STORE` 等で落ちる(2026-08-09 に卓で3件実測)。
101
+ const latticeFlag = rest.indexOf('--lattice')
102
+ if (latticeFlag >= 0) {
103
+ latticeCli = rest[latticeFlag + 1] ?? ''
104
+ if (latticeCli.length === 0) {
105
+ console.error('RUN_BRIDGE_ARGS_INVALID: --lattice には実行可能な path を渡すこと')
106
+ process.exit(1)
107
+ }
108
+ rest.splice(latticeFlag, 2)
109
+ }
110
+ if (rest.length > 0) {
111
+ // 旧版は spool dir と席名を受けていた。**黙って無視すると、配車が来ないのを不具合と読む。**
112
+ console.error(`RUN_BRIDGE_ARGS_INVALID: 余分な引数 ${rest.join(' ')}。`
113
+ + '**この常駐はもう配車をしないので spool dir も席名も取らない**(改・裁定1)。'
114
+ + '席は自分で run intake を打ち、bridge は run の進行と介入を room へ返すだけである')
108
115
  process.exit(1)
109
116
  }
110
- rest.splice(latticeFlag, 2)
111
117
  }
112
118
 
113
- const [spool, ...seats] = rest
114
- if (seats.length === 0) {
115
- console.error('RUN_BRIDGE_ARGS_INVALID: 席を1つ以上渡すこと')
116
- process.exit(1)
117
- }
118
- const ordersDir = join(spool, 'orders')
119
- const reportsDir = join(spool, 'reports')
119
+ await stopRecorded({ strict: rest[0] === '--stop' })
120
+ if (rest[0] === '--stop') process.exit(0)
121
+
120
122
  const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
121
123
  const { room, server_url: url } = state
122
124
  const token = process.env.PEERTABLE_POST_TOKEN ?? ''
123
125
  if (token.length === 0) {
124
- // 投稿できないブリッジは配車できない。起きてから黙って何もしない常駐を作らない
126
+ // 投稿できないブリッジは何も返せない。起きてから黙って何もしない常駐を作らない
125
127
  console.error('RUN_BRIDGE_TOKEN_MISSING: PEERTABLE_POST_TOKEN が無い(export し忘れていないか)')
126
128
  process.exit(1)
127
129
  }
@@ -134,136 +136,12 @@ if (selfFacts === null) {
134
136
  }
135
137
  writeFileSync(record, JSON.stringify({
136
138
  pid: process.pid, start_identity: selfFacts.startIdentity, command: selfFacts.command,
137
- room, server_url: url, spool, seats, started_at: new Date().toISOString(),
139
+ room, server_url: url, started_at: new Date().toISOString(),
138
140
  }) + '\n')
139
141
  const cleanup = () => { if (existsSync(record)) unlinkSync(record); process.exit(0) }
140
142
  process.on('SIGTERM', cleanup)
141
143
  process.on('SIGINT', cleanup)
142
144
 
143
- // ---- Lattice の canonical JSON(artifact-contracts.mjs と同じ形: key 辞書順・空白なし) ----
144
- // **別 repo なので import できない。** 形が1バイトでも違うと controller が
145
- // `work reportがpacketへexact bindしない` で落ちるので、ここは推測でなく実物と突き合わせる
146
- // (検証: test/run-bridge-canonical.test.mjs 相当を Lattice 側の probe で実施・t4 証跡)。
147
- function canonical(value) {
148
- if (value === null) return 'null'
149
- if (typeof value === 'boolean') return value ? 'true' : 'false'
150
- if (typeof value === 'string') return JSON.stringify(value)
151
- if (typeof value === 'number') {
152
- if (!Number.isFinite(value) || Object.is(value, -0)) throw new TypeError('RUN_BRIDGE_CANONICAL_INVALID')
153
- return JSON.stringify(value)
154
- }
155
- if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
156
- if (typeof value === 'object') {
157
- return `{${Object.keys(value).sort()
158
- .map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`
159
- }
160
- throw new TypeError('RUN_BRIDGE_CANONICAL_INVALID')
161
- }
162
-
163
- // ---- work order(Lattice が書く。bridge は読むだけ) ----
164
- const ORDER_KEYS = ['schema', 'todo_id', 'worktree_path', 'base_sha', 'scope_writes',
165
- 'verifier_refs', 'forbidden_operations', 'packet_digest', 'order_digest'].sort()
166
-
167
- // **正本(Lattice の `validateRunWorkOrder`)と同じ強さで拒否する。** ここが緩いと、bridge が
168
- // 不正な worktree path や scope を席へ配ってしまう——席は order を信じて worktree を触るので、
169
- // 検査を Lattice 側だけに任せられない(Lattice は席が書いた後の diff しか見ない)。
170
- const ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/
171
- const SHA256 = /^[0-9a-f]{64}$/
172
- const GIT_SHA1 = /^[0-9a-f]{40}$/
173
- const MAX_ITEMS = 256
174
-
175
- function safeWritePath(value) {
176
- return typeof value === 'string'
177
- && value.length > 0
178
- && value === posix.normalize(value)
179
- && !posix.isAbsolute(value)
180
- && value !== '..'
181
- && !value.startsWith('../')
182
- && !value.includes('\\0')
183
- && !value.includes('\0')
184
- && !['.git', '.lattice'].includes(value.split('/')[0])
185
- }
186
-
187
- function stringArray(value, { min = 0, paths = false } = {}) {
188
- return Array.isArray(value) && value.length >= min && value.length <= MAX_ITEMS
189
- && value.every(entry => (paths ? safeWritePath(entry) : typeof entry === 'string'))
190
- }
191
-
192
- function selfDigestOf(value, field) {
193
- const projection = {}
194
- for (const key of Object.keys(value)) if (key !== field) projection[key] = value[key]
195
- return createHash('sha256').update(Buffer.from(canonical(projection), 'utf8')).digest('hex')
196
- }
197
-
198
- function validOrder(order) {
199
- return order !== null && typeof order === 'object' && !Array.isArray(order)
200
- && Object.keys(order).sort().join('\0') === ORDER_KEYS.join('\0')
201
- && order.schema === 'lattice.run_work_order.v1'
202
- && ID.test(order.todo_id ?? '')
203
- && typeof order.worktree_path === 'string' && posix.isAbsolute(order.worktree_path)
204
- && GIT_SHA1.test(order.base_sha ?? '')
205
- && stringArray(order.scope_writes, { paths: true })
206
- && stringArray(order.verifier_refs)
207
- && stringArray(order.forbidden_operations, { min: 1 })
208
- && SHA256.test(order.packet_digest ?? '')
209
- && SHA256.test(order.order_digest ?? '')
210
- && selfDigestOf(order, 'order_digest') === order.order_digest
211
- }
212
-
213
- // ---- report(bridge が書く。0600・canonical JSON+LF・temp→fsync→rename) ----
214
- // controller は mode・realpath・byte 一致まで見る(0644 で書くと
215
- // `work reportがprivate canonical regular fileでない` で落ちる・実測)。
216
- async function writeReport(packetDigest, reportState, workerPid) {
217
- const report = {
218
- schema: 'lattice.run_work_report.v1',
219
- packet_digest: packetDigest,
220
- state: reportState,
221
- worker_pid: workerPid,
222
- }
223
- const target = join(reportsDir, `${packetDigest}.json`)
224
- const tmp = `${target}.bridge-tmp`
225
- await writeFile(tmp, `${canonical(report)}\n`, { mode: 0o600 })
226
- const handle = await open(tmp, 'r+')
227
- try { await handle.sync() } finally { await handle.close() }
228
- await rename(tmp, target)
229
- log(`report: ${packetDigest.slice(0, 12)} state=${reportState} worker_pid=${workerPid}`)
230
- }
231
-
232
- async function readReport(packetDigest) {
233
- try { return JSON.parse(await readFile(join(reportsDir, `${packetDigest}.json`), 'utf8')) }
234
- catch (error) { if (error?.code === 'ENOENT') return null; throw error }
235
- }
236
-
237
- // ---- 席 ----
238
- // 席本体は pane の shell ではなく、その子のうち process group leader(pid === pgid)である。
239
- async function seatWorkerPid(seat) {
240
- const panes = await run('tmux', ['-S', sock, 'list-panes', '-t', `peer-${seat}`, '-F', '#{pane_pid}'])
241
- const panePid = Number(panes.stdout.trim().split('\n')[0])
242
- if (!Number.isSafeInteger(panePid) || panePid <= 0) {
243
- throw new TypeError(`RUN_BRIDGE_SEAT_UNRESOLVED: ${seat} の pane_pid を読めない`)
244
- }
245
- const ps = await run('/bin/ps', ['-Ao', 'pid=,ppid=,pgid='], { maxBuffer: 8 * 1024 * 1024 })
246
- const leaders = ps.stdout.split('\n').map(l => l.trim().split(/\s+/).map(Number))
247
- .filter(([pid, ppid, pgid]) => ppid === panePid && pid === pgid)
248
- .map(([pid]) => pid)
249
- if (leaders.length !== 1) {
250
- // 0件(席が落ちた・pane に別のものが居る)も複数件も、推測で1つ選ばない
251
- throw new TypeError(`RUN_BRIDGE_SEAT_UNRESOLVED: ${seat} の process group leader が ${leaders.length} 件`)
252
- }
253
- return leaders[0]
254
- }
255
-
256
- // 再起動後、working report が指す pid が今どの席のものかを引き当てる。
257
- // 引き当てられなければ null(**推測で席を決めない**)。
258
- async function seatOfWorkerPid(workerPid) {
259
- for (const seat of seats) {
260
- let pid = null
261
- try { pid = await seatWorkerPid(seat) } catch { continue }
262
- if (pid === workerPid) return seat
263
- }
264
- return null
265
- }
266
-
267
145
  async function post(to, body) {
268
146
  const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, {
269
147
  method: 'POST',
@@ -273,329 +151,109 @@ async function post(to, body) {
273
151
  if (!res.ok) throw new Error(`room post ${res.status}`)
274
152
  }
275
153
 
276
- function orderText(order) {
277
- return [
278
- `[work order] ${order.todo_id}`,
279
- `worktree: ${order.worktree_path}`,
280
- `base_sha: ${order.base_sha}`,
281
- `scope_writes: ${order.scope_writes.join(', ')}`,
282
- `verifier_refs: ${order.verifier_refs.join(' / ') || '(なし)'}`,
283
- `forbidden_operations: ${order.forbidden_operations.join(', ')}`,
284
- `packet_digest: ${order.packet_digest}`,
285
- '',
286
- `受諾なら「[受諾] ${order.todo_id}」、辞退なら「[辞退] ${order.todo_id} 理由」を独立した1発言で。`,
287
- `終わったら「[完了] ${order.todo_id}」を独立した1発言で。作法は member.md の「配車で来た仕事」節。`,
288
- ].join('\n')
154
+ // cwd を project へ固定する。run ref は repo 相対なので、bridge がどこから起こされても解決する。
155
+ // **絶対 path を渡すと公開 CLI が `INVALID_RUN_REF` で拒否する**(2026-08-09 の t11 で実測)。
156
+ async function lattice(args) {
157
+ const { stdout } = await run(latticeCli, args, { cwd: proj, maxBuffer: 8 * 1024 * 1024 })
158
+ return JSON.parse(stdout)
289
159
  }
290
160
 
291
- // ---- 配車状態(記録は report が持つ。bridge 側の Map は再起動で作り直せるものだけ) ----
292
- const dispatched = new Map() // packet_digest -> { order, seat, workerPid, state, declined:Set }
161
+ const runSummaries = new Map() // run_ref -> 直近に記録した要約
162
+ const interventions = new Map() // `${run_ref}\0${task_id}` -> 直近に観測した介入の形
163
+ const closedRuns = new Set() // closed を観測した run。以後 poll しない
164
+ let pollTicks = 0
165
+
166
+ // **どの run を見るかは `run list` から取る。** 旧版は order の worktree_path から run dir を
167
+ // 切り出していたが、その order がもう出ないので、装置に聞く形へ変えた。
168
+ // `selection: 'pull'` だけを対象にする——legacy automatic run は席が居ないので中継しても意味がない。
169
+ async function pullRunRefs() {
170
+ const listed = await lattice(['run', 'list', '--json'])
171
+ return (listed.active_runs ?? [])
172
+ .filter(entry => entry.selection === 'pull' && typeof entry.run_ref === 'string')
173
+ .map(entry => entry.run_ref)
174
+ .filter(ref => !closedRuns.has(ref))
175
+ .sort()
176
+ }
293
177
 
294
- // 席が手一杯かは pane の末尾に `esc to interrupt` が在るかで見る(seat-status-bridge と同じ判定)。
295
- // **スピナーの語では判定しない**——Claude 席は動名詞を毎回変えるので語で照合すると全席 idle に見える。
296
- // `esc to interrupt` Claude 席のステータス行にも Codex 席の `Working (…)` にも入る共通marker。
297
- // 読み取りだけなので席の作業を壊さない。
298
- async function seatBusyState(seat) {
299
- const target = `peer-${seat}`
300
- let dead
301
- try { dead = (await run('tmux', ['-S', sock, 'list-panes', '-t', target, '-F', '#{pane_dead}'])).stdout }
302
- catch { return 'dead' }
303
- if (dead.trim().split('\n')[0] === '1') return 'dead'
304
- let pane
305
- try { pane = (await run('tmux', ['-S', sock, 'capture-pane', '-t', target, '-p'], { maxBuffer: 4 * 1024 * 1024 })).stdout }
306
- catch { return 'dead' }
307
- return pane.split('\n').slice(-14).join('\n').includes('esc to interrupt') ? 'busy' : 'idle'
178
+ function summarize(observation) {
179
+ const intakes = observation.intakes ?? []
180
+ const held = intakes.filter(entry => entry.intervention?.state === 'hold').map(entry => entry.task_id)
181
+ const accepted = intakes.filter(entry => entry.accepted_head_sha !== null).map(entry => entry.task_id)
182
+ const working = intakes.filter(entry => entry.accepted_head_sha === null).map(entry => entry.task_id)
183
+ return `intake=[${working}] accepted=[${accepted}] hold=[${held}] closed=${observation.closed}`
308
184
  }
309
185
 
310
- async function dispatch(order) {
311
- const key = order.packet_digest
312
- const entry = dispatched.get(key) ?? { order, seat: null, workerPid: null, state: 'pending', declined: new Set() }
313
- dispatched.set(key, entry)
314
- const occupied = seat => [...dispatched.values()]
315
- .some(other => other !== entry && other.seat === seat && !TERMINAL_ENTRY_STATES.has(other.state))
316
- const eligible = seats.filter(seat => seat !== entry.seat && !entry.declined.has(seat) && !occupied(seat))
317
- // **idle を先に当てる。** busy な席へ配ると、その席が今のターンを終えるまで受諾が来ない——
318
- // 空いている席が居るのにわざわざ待たせる理由が無い。ただし busy でも配車自体は禁じない
319
- // (席の判断で辞退できるし、全席 busy の時に配車を止めると卓が進まなくなる)。
320
- const states = await Promise.all(eligible.map(async seat => [seat, await seatBusyState(seat)]))
321
- const idle = states.find(([, state]) => state === 'idle')?.[0]
322
- const busy = states.find(([, state]) => state === 'busy')?.[0]
323
- const candidate = idle ?? busy
324
- if (candidate === undefined) {
325
- const detail = states.map(([seat, state]) => `${seat}:${state}`).join(' ') || '候補なし'
326
- log(`配車できる席が無い: ${order.todo_id}(辞退 ${[...entry.declined].join(',') || 'なし'}・${detail})`)
327
- entry.seat = null
328
- return
329
- }
330
- if (idle === undefined) log(`idle な席が無いので busy な席へ配車する: ${order.todo_id} → ${candidate}`)
331
- entry.seat = candidate
332
- entry.state = 'offered'
333
- await post(candidate, orderText(order))
334
- await post('all', `[配車] ${order.todo_id} → ${candidate}`)
335
- log(`配車: ${order.todo_id} → ${candidate}`)
186
+ // 介入は**その作業を始めた席**へ返す。誰が始めたかは観測が持っている(`intake.actor.agent`)ので、
187
+ // bridge Todo store の内部構造を読みに行く必要はない。
188
+ function interventionText(runRef, intake) {
189
+ const reason = intake.intervention?.reason ?? '(理由なし)'
190
+ return [
191
+ `[介入] ${intake.task_id} ${intake.intervention?.state}`,
192
+ `run: ${runRef}`,
193
+ `理由: ${reason}`,
194
+ `worktree: ${intake.worktree_path}`,
195
+ '',
196
+ '装置が競合を見て**留まれ**と言っている。作業を止めて room で調整するか、',
197
+ `解消したら \`lattice run intake intervention --run ${runRef} --task ${intake.task_id}\` で読み直す。`,
198
+ ].join('\n')
336
199
  }
337
200
 
338
- async function scanOrders() {
339
- // **SSE の頭出しが済むまで配車しない。** 頭出しは「ここまでは既読」と決める操作なので、
340
- // 先に配車すると、その直後に返ってきた `[受諾]` が既読として捨てられる(実測で踏んだ順序)。
341
- if (!primed) return
342
- let names
343
- try { names = await readdir(ordersDir) }
201
+ async function pollRuns() {
202
+ let refs
203
+ try { refs = await pullRunRefs() }
344
204
  catch (error) {
345
- if (error?.code === 'ENOENT') { log(`orders directory がまだ無い: ${ordersDir}`); return }
346
- throw error
205
+ // 観測できないことを黙らない。**沈黙を「異常なし」の証拠にしない**
206
+ const detail = String(error?.stderr ?? error?.message ?? error).split('\n')[0].slice(0, 200)
207
+ log(`run list 失敗: ${detail}`)
208
+ return
347
209
  }
348
- let fresh = 0
349
- for (const name of names.filter(n => n.endsWith('.json')).sort()) {
350
- const digest = name.slice(0, -'.json'.length)
351
- if (dispatched.has(digest)) continue
352
- let order
353
- try { order = JSON.parse(await readFile(join(ordersDir, name), 'utf8')) }
354
- catch (error) { log(`order を読めない(飛ばす): ${name}: ${error.message}`); continue }
355
- if (!validOrder(order) || order.packet_digest !== digest) {
356
- // 契約違反は黙って飲まない。ただし他の order の配車は止めない
357
- log(`RUN_BRIDGE_ORDER_INVALID: ${name} が run_work_order.v1 契約を満たさない(飛ばす)`)
358
- dispatched.set(digest, { order: null, seat: null, workerPid: null, state: 'invalid', declined: new Set() })
359
- continue
360
- }
361
- // 再起動時、durable な状態は report が持つ。**working を fresh 扱いで配り直さない**——
362
- // 配り直すと worker_pid が変わり、controller が hard fail する(受諾後の pid は不変契約)。
363
- const existing = await readReport(digest)
364
- if (existing !== null && existing.state === 'done') {
365
- dispatched.set(digest, { order, seat: null, workerPid: existing.worker_pid, state: 'done', declined: new Set() })
366
- log(`済んだ order を引き継いだ: ${order.todo_id}`)
210
+ for (const ref of refs) {
211
+ let observation
212
+ try { observation = await lattice(['run', 'observe', '--run', ref]) }
213
+ catch (error) {
214
+ const detail = String(error?.stderr ?? error?.message ?? error).split('\n')[0].slice(0, 200)
215
+ const summary = `observe 失敗: ${detail}`
216
+ // 1 run の失敗で他の run の報告を止めない
217
+ if (runSummaries.get(ref) !== summary) { runSummaries.set(ref, summary); log(`${ref}: ${summary}`) }
367
218
  continue
368
219
  }
369
- if (existing !== null && existing.state === 'working') {
370
- const owner = await seatOfWorkerPid(existing.worker_pid)
371
- if (owner === null) {
372
- // 復元できないなら**止まる**。別の席へ配り直すのは契約違反で、静かに壊れるより悪い
373
- dispatched.set(digest, { order, seat: null, workerPid: existing.worker_pid, state: 'unrecoverable', declined: new Set() })
374
- log(`RUN_BRIDGE_WORKING_UNRECOVERABLE: ${order.todo_id} working report が指す pid `
375
- + `${existing.worker_pid} を持つ席が居ない。**再配車しない**(worker_pid 不変契約を破るため)。`
376
- + '席が落ちているなら run 側で hold/close して order を出し直すこと')
220
+
221
+ // 介入は run 要約より先に返す。**留まれという指示が要約の後ろに埋もれない**ようにする
222
+ for (const intake of observation.intakes ?? []) {
223
+ const key = `${ref}\0${intake.task_id}`
224
+ const shape = `${intake.intervention?.state}\0${intake.intervention?.reason ?? ''}`
225
+ if (interventions.get(key) === shape) continue // 変化が無い間は鳴らさない
226
+ interventions.set(key, shape)
227
+ if (intake.intervention?.state !== 'hold') continue // none は静かに通す(通知は要らない)
228
+ const seat = intake.actor?.agent
229
+ if (typeof seat !== 'string' || seat.length === 0) {
230
+ // broadcast は存在しない。宛先不明を黙らせず、local logへtypedに残す。
231
+ log(`RUN_BRIDGE_RECIPIENT_UNKNOWN: 介入の宛先を決められない: ${ref} ${intake.task_id}`)
377
232
  continue
378
233
  }
379
- dispatched.set(digest, { order, seat: owner, workerPid: existing.worker_pid, state: 'working', declined: new Set() })
380
- log(`作業中の order を引き継いだ: ${order.todo_id} ← ${owner}(pid ${existing.worker_pid})`)
381
- continue
234
+ log(`介入: ${ref} ${intake.task_id} → ${seat}(${intake.intervention.reason ?? ''})`)
235
+ await post(seat, interventionText(ref, intake))
382
236
  }
383
- fresh += 1
384
- await dispatch(order)
385
- }
386
- // 2秒ごとに全走査を喋ると1時間で1800行になる。**黙らせはしない**(沈黙を「異常なし」の
387
- // 証拠にしない)ので、変化が無い時も1分に1回は件数を出す
388
- scanTicks += 1
389
- if (fresh > 0 || scanTicks % 30 === 1) {
390
- log(`orders ${names.length} 件を見て ${fresh} 件を新規配車した`)
391
- }
392
- }
393
- let scanTicks = 0
394
-
395
- // ---- room の SSE(wakeup-bridge と同じ三段: 無音watchdog・再接続catch-up・心拍差分) ----
396
- const IDLE_MS = 75_000
397
- let lastSeq = 0
398
- let primed = false
399
- let catching = false
400
237
 
401
- // 席の宣言は `tN` しか運ばないので、(席, todo_id) で配車記録を引く。**終端済みの記録は飛ばす**——
402
- // 同じ todo_id を再 run すると、前回の `done` 記録が先に当たって新しい受諾が「重複」として捨てられる
403
- // (2026-08-09 の t7 受入で実測。席から見ると「受諾したのに何も起きない」になる)。
404
- const TERMINAL_ENTRY_STATES = new Set(['done', 'unrecoverable', 'invalid'])
405
-
406
- function findEntry(seat, todoId) {
407
- for (const entry of dispatched.values()) {
408
- if (entry.seat !== seat || entry.order?.todo_id !== todoId) continue
409
- if (TERMINAL_ENTRY_STATES.has(entry.state)) continue
410
- return entry
411
- }
412
- return null
413
- }
414
-
415
- async function handleMessage(msg) {
416
- if (typeof msg.body !== 'string' || typeof msg.from !== 'string') return
417
- const match = msg.body.match(/^\[(受諾|辞退|完了)\]\s*(\S+)/u)
418
- if (match === null) return
419
- const [, verb, todoId] = match
420
- const entry = findEntry(msg.from, todoId)
421
- if (entry === null) {
422
- log(`宛先の無い宣言(無視): ${msg.from} [${verb}] ${todoId}`)
423
- return
424
- }
425
- if (verb === '辞退') {
426
- log(`辞退: ${todoId} ← ${msg.from}`)
427
- entry.declined.add(msg.from)
428
- entry.state = 'pending'
429
- await dispatch(entry.order)
430
- return
431
- }
432
- if (verb === '受諾') {
433
- if (entry.state !== 'offered') { log(`重複した受諾(無視): ${todoId} ← ${msg.from}`); return }
434
- // **ここで初めて report を書く。** pid は受諾した席のもので、以後不変(controller が
435
- // 途中変化を hard fail する)。だから辞退の受け直しは受諾より前にしか起きない
436
- entry.workerPid = await seatWorkerPid(msg.from)
437
- entry.state = 'working'
438
- await writeReport(entry.order.packet_digest, 'working', entry.workerPid)
439
- return
440
- }
441
- if (entry.state !== 'working') { log(`受諾前の完了宣言(無視): ${todoId} ← ${msg.from}`); return }
442
- entry.state = 'done'
443
- await writeReport(entry.order.packet_digest, 'done', entry.workerPid)
444
- log(`完了: ${todoId} ← ${msg.from}`)
445
- }
446
-
447
- async function catchUp(reason) {
448
- if (catching) return
449
- catching = true
450
- try {
451
- const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages?since=${lastSeq}`)
452
- if (!res.ok) throw new Error(`messages ${res.status}`)
453
- const { messages } = await res.json()
454
- if (!primed) {
455
- primed = true
456
- if (messages.length > 0) lastSeq = messages[messages.length - 1].seq
457
- log(`頭出し: seq ${lastSeq} まで既読として開始する`)
458
- return
238
+ const summary = summarize(observation)
239
+ if (runSummaries.get(ref) !== summary) {
240
+ runSummaries.set(ref, summary)
241
+ log(`run 進行: ${ref} ${summary}`)
459
242
  }
460
- log(`取りこぼし確認(${reason}・since ${lastSeq}): ${messages.length} 件`)
461
- for (const msg of messages) await ingest(msg)
462
- } finally {
463
- catching = false
464
- }
465
- }
466
-
467
- async function ingest(msg) {
468
- if (typeof msg.seq !== 'number') { log(`seq を持たないイベントを捨てた`); return }
469
- if (msg.seq <= lastSeq) return
470
- lastSeq = msg.seq
471
- try { await handleMessage(msg) }
472
- catch (error) { log(`宣言の処理に失敗(続行する): ${error.message}`) }
473
- }
474
-
475
- function onHeartbeat(dataLine) {
476
- const head = Number(dataLine)
477
- if (!Number.isFinite(head) || head <= lastSeq) return
478
- log(`心拍が示す最新 seq ${head} に追いついていない(手元 ${lastSeq})`)
479
- catchUp('心拍の差分').catch(error => log(`心拍由来の回収に失敗: ${error.message}`))
480
- }
481
-
482
- // ---- run の進行を room へ返す(`lattice run observe` の read-only polling) ----
483
- // **order から run を知る。** worktree_path は `<repo>/.lattice/runs/<run-id>/worktrees/…` なので、
484
- // そこから run dir を切り出せる。別 config を増やさずに済み、複数 run が同時に走っても取り違えない。
485
- function runDirOf(order) {
486
- const marker = `${sep}.lattice${sep}runs${sep}`
487
- const at = order.worktree_path.indexOf(marker)
488
- if (at < 0) return null
489
- const rest = order.worktree_path.slice(at + marker.length)
490
- const runId = rest.split(sep)[0]
491
- return runId ? order.worktree_path.slice(0, at + marker.length) + runId : null
492
- }
493
-
494
- const observedRuns = new Map() // runDir -> 直近に投稿した要約
495
- const closedRuns = new Set() // closed を観測した run。以後 poll しない
496
- const unlocatableOrders = new Set() // run dir を切り出せなかった order(1回だけ鳴らす)
497
-
498
- async function pollRuns() {
499
- const targets = new Set()
500
- for (const entry of dispatched.values()) {
501
- if (entry.order === null) continue
502
- const dir = runDirOf(entry.order)
503
- if (dir === null) {
504
- // **黙って可視化を落とさない。** `run_work_order.v1` が保証するのは絶対 path までで、
505
- // `.lattice/runs/<id>/worktrees` 配置は schema の保証外。配置が変わるとここへ落ちるので、
506
- // order ごとに1回は理由を鳴らす(配車自体は続ける——観測できないだけで配車は成立する)
507
- if (!unlocatableOrders.has(entry.order.packet_digest)) {
508
- unlocatableOrders.add(entry.order.packet_digest)
509
- log(`RUN_BRIDGE_RUN_DIR_UNLOCATABLE: ${entry.order.todo_id} の worktree_path から run dir を切り出せない`
510
- + `(${entry.order.worktree_path})。この order の run 進行は room へ返せない`)
511
- }
512
- continue
513
- }
514
- // closed を観測した run を回し続けると、run が増えるほど外部 CLI の起動が積み上がる
515
- if (closedRuns.has(dir)) continue
516
- targets.add(dir)
517
- }
518
- for (const dir of [...targets].sort()) {
519
- let observation
520
- try {
521
- const { stdout } = await run(latticeCli, ['run', 'observe', '--run', dir], { maxBuffer: 4 * 1024 * 1024 })
522
- observation = JSON.parse(stdout)
523
- } catch (error) {
524
- // 観測できないことを黙らない。ただし1 run の失敗で他の run の報告を止めない
525
- const detail = String(error?.stderr ?? error?.message ?? error).split('\n')[0].slice(0, 200)
526
- const summary = `observe 失敗: ${detail}`
527
- if (observedRuns.get(dir) !== summary) { observedRuns.set(dir, summary); log(`${dir}: ${summary}`) }
528
- continue
529
- }
530
- // **`running` を先頭に置く。** 「進行中要約」を返すのが役目なのに、いちばん進行中を表す field を
531
- // 落としていた(t10 監査で発覚。完走後の run だけで動作確認したので気づかなかった)
532
- const summary = `running=[${observation.running}] accepted=[${observation.accepted}]`
533
- + ` terminal=[${observation.terminal}] hold=${observation.hold_count}`
534
- + ` conflict=${observation.conflict_count} closed=${observation.closed}`
535
- if (observedRuns.get(dir) === summary) continue // 変化が無い時は room を鳴らさない
536
- observedRuns.set(dir, summary)
537
- log(`run 進行: ${dir} ${summary}`)
538
- await post('all', `[run] ${dir.split(sep).pop()} ${summary}`)
539
- // closed は終端。**最後の1回は必ず投稿してから**外す
243
+ // closed は終端。最後の状態をlocal logへ記録してから外す
540
244
  if (observation.closed === true) {
541
- closedRuns.add(dir)
542
- log(`run 終端を観測したので poll を止める: ${dir}`)
245
+ closedRuns.add(ref)
246
+ log(`run 終端を観測したので poll を止める: ${ref}`)
543
247
  }
544
248
  }
249
+ // 変化が無い時も1分に1回は件数を出す(沈黙を「異常なし」の証拠にしない)
250
+ pollTicks += 1
251
+ if (pollTicks % 6 === 1) log(`pull run ${refs.length} 件を観測中`)
545
252
  }
546
253
 
547
- setInterval(() => { scanOrders().catch(error => log(`order 走査に失敗: ${error.message}`)) }, 2000)
548
- setInterval(() => { pollRuns().catch(error => log(`run 観測に失敗: ${error.message}`)) }, 10_000)
549
-
550
- let failures = 0
551
- log(`bridge start: room=${room} spool=${spool} seats=${seats.join(',')} pid=${process.pid}`)
254
+ log(`bridge start: room=${room} project=${proj} pid=${process.pid}(配車はしない・観測と中継だけ)`)
552
255
  for (;;) {
553
- try {
554
- const abort = new AbortController()
555
- let lastByteAt = Date.now()
556
- const watchdog = setInterval(() => {
557
- if (Date.now() - lastByteAt > IDLE_MS) {
558
- log(`受信途絶 ${Math.round(IDLE_MS / 1000)} 秒。接続が黙って死んだとみなして繋ぎ直す`)
559
- abort.abort()
560
- }
561
- }, 5000)
562
- try {
563
- const res = await fetch(`${url}/api/${encodeURIComponent(room)}/events`, { signal: abort.signal })
564
- if (!res.ok) throw new Error(`events ${res.status}`)
565
- failures = 0
566
- log('SSE 接続')
567
- await catchUp('再接続')
568
- let buf = ''
569
- for await (const chunk of res.body) {
570
- lastByteAt = Date.now()
571
- buf += Buffer.from(chunk).toString('utf8')
572
- const parts = buf.split('\n\n')
573
- buf = parts.pop()
574
- for (const part of parts) {
575
- const lines = part.split('\n')
576
- const name = lines.find(l => l.startsWith('event: '))?.slice(7).trim()
577
- const line = lines.find(l => l.startsWith('data: '))
578
- if (name === 'ping') { if (line) onHeartbeat(line.slice(6)); continue }
579
- if (name !== undefined && name !== 'message') continue
580
- if (line) await ingest(JSON.parse(line.slice(6)))
581
- }
582
- }
583
- log('SSE 切断(再接続する)')
584
- } finally {
585
- clearInterval(watchdog)
586
- }
587
- } catch (error) {
588
- if (error.name === 'AbortError') {
589
- log('再接続する')
590
- } else {
591
- failures++
592
- log(`SSE 失敗 ${failures} 回目: ${error.message}`)
593
- if (failures >= 10) {
594
- console.error('RUN_BRIDGE_UNREACHABLE: room の SSE へ10回連続で繋げない')
595
- if (existsSync(record)) unlinkSync(record)
596
- process.exit(1)
597
- }
598
- }
599
- }
600
- await sleep(2000)
256
+ try { await pollRuns() }
257
+ catch (error) { log(`run 観測に失敗(続行する): ${error.message}`) }
258
+ await sleep(10_000)
601
259
  }