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.
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/room/client.mjs +13 -13
- package/room/server.mjs +79 -20
- package/skill/SKILL.md +47 -27
- package/skill/scripts/change-effort.sh +139 -0
- package/skill/scripts/launch-seat.sh +80 -0
- package/skill/scripts/parent-join.sh +4 -19
- package/skill/scripts/run-bridge.mjs +127 -469
- package/skill/scripts/seat-status-bridge.mjs +34 -14
- package/skill/scripts/seat-usage.mjs +26 -0
- package/skill/scripts/setup.sh +15 -62
- package/skill/scripts/teardown.sh +108 -5
- package/skill/scripts/wakeup-bridge.mjs +5 -6
- package/skill/templates/charter.md +5 -5
- package/skill/templates/done.sh +114 -5
- package/skill/templates/member-standalone.md +7 -3
- package/skill/templates/member.md +89 -27
|
@@ -1,43 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Lattice の
|
|
3
|
-
// 送らない(room 経由で配車する)。
|
|
2
|
+
// Lattice の pull 型 run を円卓へ可視化する常駐。**AI ではない**し、**席へ1バイトも送らない**。
|
|
4
3
|
//
|
|
5
|
-
// usage: run-bridge.mjs <project_dir>
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
12
|
+
// 残った役目は2つだけで、**どちらも読み取りである**:
|
|
13
|
+
// 1. pull run の進行(`lattice run observe`)を、変化した時だけ room へ1行返す
|
|
14
|
+
// 2. 装置が出した介入(hold)を、その作業を始めた席宛に room へ返す
|
|
17
15
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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 {
|
|
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
|
|
35
|
-
console.error('usage: run-bridge.mjs <project_dir>
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// `--
|
|
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 (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
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,
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
292
|
-
const
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
339
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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(
|
|
542
|
-
log(`run 終端を観測したので poll を止める: ${
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
}
|