peertable 0.3.2 → 0.3.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.
- package/README.ja.md +3 -1
- package/README.md +2 -2
- package/package.json +2 -1
- package/room/client.mjs +24 -7
- package/room/server.mjs +23 -7
- package/skill/SKILL.md +71 -2
- package/skill/scripts/archive-room-log.py +59 -0
- package/skill/scripts/run-bridge.mjs +601 -0
- package/skill/scripts/setup.sh +96 -2
- package/skill/scripts/teardown.sh +127 -8
- package/skill/scripts/wakeup-bridge.mjs +3 -1
- package/skill/templates/charter.md +4 -4
- package/skill/templates/member.md +35 -3
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Lattice の managed run と円卓の席をつなぐ配車ブリッジ。**AI ではない常駐**で、席へ1バイトも
|
|
3
|
+
// 送らない(room 経由で配車する)。
|
|
4
|
+
//
|
|
5
|
+
// usage: run-bridge.mjs <project_dir> <spool_dir> <seat> [seat...] 起動(前面。nohup で常駐させる)
|
|
6
|
+
// run-bridge.mjs <project_dir> --stop 停止
|
|
7
|
+
//
|
|
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 を書く
|
|
13
|
+
//
|
|
14
|
+
// **席は spool に触れない。** 席が読むのは room、書くのは worktree だけである(席の作法の正本は
|
|
15
|
+
// templates/member.md の「配車で来た仕事」節)。report を書けるのは bridge だけで、Lattice は
|
|
16
|
+
// report を「状態遷移の合図」としてしか使わない——diff は Lattice が worktree から独立に撮る。
|
|
17
|
+
//
|
|
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と共有している` で正しく落ちる。
|
|
22
|
+
//
|
|
23
|
+
// 生死の作法は Lattice ADR 0157 に倣う: 自分の pid を記録に置き、起動時に前の記録を掃除し、
|
|
24
|
+
// 止まらなければ黙って諦めず typed error で落ちる。
|
|
25
|
+
import { execFile } from 'node:child_process'
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
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'
|
|
30
|
+
import { promisify } from 'node:util'
|
|
31
|
+
|
|
32
|
+
const run = promisify(execFile)
|
|
33
|
+
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')
|
|
36
|
+
process.exit(1)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
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
|
+
const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
42
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
43
|
+
const log = line => console.log(`[${new Date().toISOString()}] ${line}`)
|
|
44
|
+
|
|
45
|
+
// pid だけを頼りに signal を送らない(ADR 0157)。**pid は再利用される**ので、記録した
|
|
46
|
+
// 起動時刻(ps の lstart)と command line を照合し、通った相手にだけ送る。照合が合わない記録は
|
|
47
|
+
// 「自分の常駐ではない誰か」なので、掃除はしても**殺さない**。
|
|
48
|
+
async function processFacts(pid) {
|
|
49
|
+
let stdout
|
|
50
|
+
try { ({ stdout } = await run('/bin/ps', ['-o', 'lstart=,command=', '-p', String(pid)])) }
|
|
51
|
+
catch { return null }
|
|
52
|
+
const line = stdout.split('\n')[0]?.trim() ?? ''
|
|
53
|
+
if (line.length === 0) return null
|
|
54
|
+
// lstart は固定幅の `Sun Aug 9 08:11:02 2026`(5 token)。残りが command line
|
|
55
|
+
const parts = line.split(/\s+/)
|
|
56
|
+
return { startIdentity: parts.slice(0, 5).join(' '), command: parts.slice(5).join(' ') }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function stopRecorded({ strict = false } = {}) {
|
|
60
|
+
if (!existsSync(record)) return
|
|
61
|
+
const stored = JSON.parse(readFileSync(record, 'utf8'))
|
|
62
|
+
const { pid } = stored
|
|
63
|
+
const facts = await processFacts(pid)
|
|
64
|
+
if (facts === null || !alive(pid)) {
|
|
65
|
+
unlinkSync(record); log(`死んだ記録を掃除した(pid ${pid})`); return
|
|
66
|
+
}
|
|
67
|
+
const sameProcess = stored.start_identity === undefined
|
|
68
|
+
? false // 旧形式の記録は再認証できない=殺さない
|
|
69
|
+
: facts.startIdentity === stored.start_identity
|
|
70
|
+
&& facts.command.includes('run-bridge.mjs')
|
|
71
|
+
&& facts.command.includes(proj)
|
|
72
|
+
if (!sameProcess) {
|
|
73
|
+
unlinkSync(record)
|
|
74
|
+
const detail = `pid ${pid} は記録した常駐ではない(観測: ${facts.startIdentity} / ${facts.command.slice(0, 120)})`
|
|
75
|
+
if (strict) {
|
|
76
|
+
console.error(`RUN_BRIDGE_RECORD_STALE: ${detail}。**signal は送っていない**——`
|
|
77
|
+
+ '本物の常駐が別 pid で生きている可能性があるので、`ps` で確認して手で止めること')
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
log(`RUN_BRIDGE_RECORD_STALE: ${detail}。signal を送らずに記録だけ掃除した`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
process.kill(pid, 'SIGTERM')
|
|
84
|
+
for (let i = 0; i < 25 && alive(pid); i++) await sleep(200)
|
|
85
|
+
if (alive(pid)) {
|
|
86
|
+
process.kill(pid, 'SIGKILL')
|
|
87
|
+
for (let i = 0; i < 15 && alive(pid); i++) await sleep(200)
|
|
88
|
+
}
|
|
89
|
+
if (alive(pid)) {
|
|
90
|
+
console.error(`RUN_BRIDGE_STOP_FAILED: pid ${pid} が SIGKILL でも止まらない`)
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
if (existsSync(record)) unlinkSync(record)
|
|
94
|
+
log(`前のブリッジを停止した(pid ${pid})`)
|
|
95
|
+
}
|
|
96
|
+
|
|
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')
|
|
103
|
+
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 を渡すこと')
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
rest.splice(latticeFlag, 2)
|
|
111
|
+
}
|
|
112
|
+
|
|
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')
|
|
120
|
+
const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
|
|
121
|
+
const { room, server_url: url } = state
|
|
122
|
+
const token = process.env.PEERTABLE_POST_TOKEN ?? ''
|
|
123
|
+
if (token.length === 0) {
|
|
124
|
+
// 投稿できないブリッジは配車できない。起きてから黙って何もしない常駐を作らない
|
|
125
|
+
console.error('RUN_BRIDGE_TOKEN_MISSING: PEERTABLE_POST_TOKEN が無い(export し忘れていないか)')
|
|
126
|
+
process.exit(1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 記録には pid だけでなく**起動時刻と command line**を入れる。停止側はこれで再認証する
|
|
130
|
+
const selfFacts = await processFacts(process.pid)
|
|
131
|
+
if (selfFacts === null) {
|
|
132
|
+
console.error('RUN_BRIDGE_SELF_UNOBSERVABLE: 自分の process を ps で観測できない')
|
|
133
|
+
process.exit(1)
|
|
134
|
+
}
|
|
135
|
+
writeFileSync(record, JSON.stringify({
|
|
136
|
+
pid: process.pid, start_identity: selfFacts.startIdentity, command: selfFacts.command,
|
|
137
|
+
room, server_url: url, spool, seats, started_at: new Date().toISOString(),
|
|
138
|
+
}) + '\n')
|
|
139
|
+
const cleanup = () => { if (existsSync(record)) unlinkSync(record); process.exit(0) }
|
|
140
|
+
process.on('SIGTERM', cleanup)
|
|
141
|
+
process.on('SIGINT', cleanup)
|
|
142
|
+
|
|
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
|
+
async function post(to, body) {
|
|
268
|
+
const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: { 'content-type': 'application/json', 'x-peertable-token': token },
|
|
271
|
+
body: JSON.stringify({ from: 'run-bridge', to, body }),
|
|
272
|
+
})
|
|
273
|
+
if (!res.ok) throw new Error(`room post ${res.status}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
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')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- 配車状態(記録は report が持つ。bridge 側の Map は再起動で作り直せるものだけ) ----
|
|
292
|
+
const dispatched = new Map() // packet_digest -> { order, seat, workerPid, state, declined:Set }
|
|
293
|
+
|
|
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'
|
|
308
|
+
}
|
|
309
|
+
|
|
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}`)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function scanOrders() {
|
|
339
|
+
// **SSE の頭出しが済むまで配車しない。** 頭出しは「ここまでは既読」と決める操作なので、
|
|
340
|
+
// 先に配車すると、その直後に返ってきた `[受諾]` が既読として捨てられる(実測で踏んだ順序)。
|
|
341
|
+
if (!primed) return
|
|
342
|
+
let names
|
|
343
|
+
try { names = await readdir(ordersDir) }
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (error?.code === 'ENOENT') { log(`orders directory がまだ無い: ${ordersDir}`); return }
|
|
346
|
+
throw error
|
|
347
|
+
}
|
|
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}`)
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
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 を出し直すこと')
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
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
|
|
382
|
+
}
|
|
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
|
+
|
|
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
|
|
459
|
+
}
|
|
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回は必ず投稿してから**外す
|
|
540
|
+
if (observation.closed === true) {
|
|
541
|
+
closedRuns.add(dir)
|
|
542
|
+
log(`run 終端を観測したので poll を止める: ${dir}`)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
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}`)
|
|
552
|
+
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)
|
|
601
|
+
}
|