peertable 0.4.36 → 0.5.0

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,272 +0,0 @@
1
- #!/usr/bin/env node
2
- // Lattice の pull 型 run を円卓へ可視化する常駐。**AI ではない**し、**席へ1バイトも送らない**。
3
- //
4
- // usage: run-bridge.mjs <project_dir> [--lattice <path>] 起動(前面。nohup で常駐させる)
5
- // run-bridge.mjs <project_dir> --stop 停止
6
- //
7
- // **この常駐は仕事を配らない。** 2026-08-09 のオーナー裁定(改・裁定1)で、装置が席を選んで
8
- // 仕事を配る向きは撤回された——**作業を選ぶのも始めるのも AI** であり、Lattice がやるのは
9
- // 着手済み ToDo 間の競合判定と介入だけである。旧版が持っていた席選定・`[配車]` 投稿・
10
- // `[受諾]`/`[辞退]` の再配車・work report の書き込みは、その向きの部品なので**全部落とした**。
11
- //
12
- // 残った役目は2つだけで、**どちらも読み取りである**:
13
- // 1. pull run の進行(`lattice run observe`)を、変化した時だけ room へ1行返す
14
- // 2. 装置が出した介入(hold)を、その作業を始めた席宛に room へ返す
15
- //
16
- // **この常駐は必須経路ではない。** 席は自分で `run intake` / `attach` / `accept` を打ち、
17
- // 介入は `run intake intervention` で自分でも読めるので、bridge が落ちていても作業は進む——
18
- // 見えなくなるだけである(peertable 決定63・相互独立)。
19
- // 書き込む先は room だけで、spool にも run store にも1バイトも書かない。
20
- //
21
- // 生死の作法は Lattice ADR 0157 に倣う: 自分の pid を記録に置き、起動時に前の記録を掃除し、
22
- // 止まらなければ黙って諦めず typed error で落ちる。
23
- import { execFile } from 'node:child_process'
24
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
25
- import { join } from 'node:path'
26
- import { promisify } from 'node:util'
27
-
28
- import { resolvePostToken } from './seat-usage.mjs'
29
-
30
- const run = promisify(execFile)
31
- const [proj, ...rest] = process.argv.slice(2)
32
- if (!proj) {
33
- console.error('usage: run-bridge.mjs <project_dir> [--lattice <path>] | <project_dir> --stop')
34
- process.exit(1)
35
- }
36
-
37
- const record = join(proj, '.team', 'run-bridge.json')
38
- const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
39
- const sleep = ms => new Promise(r => setTimeout(r, ms))
40
- const log = line => console.log(`[${new Date().toISOString()}] ${line}`)
41
-
42
- // pid だけを頼りに signal を送らない(ADR 0157)。**pid は再利用される**ので、記録した
43
- // 起動時刻(ps の lstart)と command line を照合し、通った相手にだけ送る。照合が合わない記録は
44
- // 「自分の常駐ではない誰か」なので、掃除はしても**殺さない**。
45
- async function processFacts(pid) {
46
- let stdout
47
- try { ({ stdout } = await run('/bin/ps', ['-o', 'lstart=,command=', '-p', String(pid)])) }
48
- catch { return null }
49
- const line = stdout.split('\n')[0]?.trim() ?? ''
50
- if (line.length === 0) return null
51
- // lstart は固定幅の `Sun Aug 9 08:11:02 2026`(5 token)。残りが command line
52
- const parts = line.split(/\s+/)
53
- return { startIdentity: parts.slice(0, 5).join(' '), command: parts.slice(5).join(' ') }
54
- }
55
-
56
- async function stopRecorded({ strict = false } = {}) {
57
- if (!existsSync(record)) return
58
- const stored = JSON.parse(readFileSync(record, 'utf8'))
59
- const { pid } = stored
60
- const facts = await processFacts(pid)
61
- if (facts === null || !alive(pid)) {
62
- unlinkSync(record); log(`死んだ記録を掃除した(pid ${pid})`); return
63
- }
64
- const sameProcess = stored.start_identity === undefined
65
- ? false // 旧形式の記録は再認証できない=殺さない
66
- : facts.startIdentity === stored.start_identity
67
- && facts.command.includes('run-bridge.mjs')
68
- && facts.command.includes(proj)
69
- if (!sameProcess) {
70
- unlinkSync(record)
71
- const detail = `pid ${pid} は記録した常駐ではない(観測: ${facts.startIdentity} / ${facts.command.slice(0, 120)})`
72
- if (strict) {
73
- console.error(`RUN_BRIDGE_RECORD_STALE: ${detail}。**signal は送っていない**——`
74
- + '本物の常駐が別 pid で生きている可能性があるので、`ps` で確認して手で止めること')
75
- process.exit(1)
76
- }
77
- log(`RUN_BRIDGE_RECORD_STALE: ${detail}。signal を送らずに記録だけ掃除した`)
78
- return
79
- }
80
- process.kill(pid, 'SIGTERM')
81
- for (let i = 0; i < 25 && alive(pid); i++) await sleep(200)
82
- if (alive(pid)) {
83
- process.kill(pid, 'SIGKILL')
84
- for (let i = 0; i < 15 && alive(pid); i++) await sleep(200)
85
- }
86
- if (alive(pid)) {
87
- console.error(`RUN_BRIDGE_STOP_FAILED: pid ${pid} が SIGKILL でも止まらない`)
88
- process.exit(1)
89
- }
90
- if (existsSync(record)) unlinkSync(record)
91
- log(`前のブリッジを停止した(pid ${pid})`)
92
- }
93
-
94
- // **引数の検査は前のブリッジを止めるより先にやる。** 逆順にすると、旧版の command line
95
- // (spool dir と席名つき)で叩いた人が「動いているブリッジを殺してから起動に失敗する」——
96
- // 正典が旧形式を載せていた期間があるので、これは実際に起きる(自分で踏んで気づいた)。
97
- // `--stop` はここを通さない。止めるだけの呼び出しに `--lattice` は要らない。
98
- let latticeCli = 'lattice'
99
- if (rest[0] !== '--stop') {
100
- // `--lattice <path>` は任意。既定は PATH 上の `lattice`。**release 前の source tree を実測する時**は
101
- // ここで実物を指す。PATH の install は publish 済みの版なので、**version 表示が同じでも**
102
- // 未 publish の schema を読めず `INVALID_RUN_STORE` 等で落ちる(2026-08-09 に卓で3件実測)。
103
- const latticeFlag = rest.indexOf('--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
- if (rest.length > 0) {
113
- // 旧版は spool dir と席名を受けていた。**黙って無視すると、配車が来ないのを不具合と読む。**
114
- console.error(`RUN_BRIDGE_ARGS_INVALID: 余分な引数 ${rest.join(' ')}。`
115
- + '**この常駐はもう配車をしないので spool dir も席名も取らない**(改・裁定1)。'
116
- + '席は自分で run intake を打ち、bridge は run の進行と介入を room へ返すだけである')
117
- process.exit(1)
118
- }
119
- }
120
-
121
- await stopRecorded({ strict: rest[0] === '--stop' })
122
- if (rest[0] === '--stop') process.exit(0)
123
-
124
- const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
125
- const { room, server_url: url } = state
126
- // launch-seat.sh:25-27 と同じ解決規則(env が先・無ければ `~/.config/peertable.env`)。
127
- // **`export` の有無に常駐の生死を握らせない**——seat-status-bridge が同じ依存で4時間死んだ(2026-08-10)
128
- const token = resolvePostToken(process.env)
129
- if (token.length === 0) {
130
- // 投稿できないブリッジは何も返せない。起きてから黙って何もしない常駐を作らない
131
- console.error('RUN_BRIDGE_TOKEN_MISSING: 書込トークンが無い(環境変数 PEERTABLE_POST_TOKEN か ~/.config/peertable.env)')
132
- process.exit(1)
133
- }
134
-
135
- // 記録には pid だけでなく**起動時刻と command line**を入れる。停止側はこれで再認証する
136
- const selfFacts = await processFacts(process.pid)
137
- if (selfFacts === null) {
138
- console.error('RUN_BRIDGE_SELF_UNOBSERVABLE: 自分の process を ps で観測できない')
139
- process.exit(1)
140
- }
141
- writeFileSync(record, JSON.stringify({
142
- pid: process.pid, start_identity: selfFacts.startIdentity, command: selfFacts.command,
143
- room, server_url: url, started_at: new Date().toISOString(),
144
- }) + '\n')
145
- const cleanup = () => { if (existsSync(record)) unlinkSync(record); process.exit(0) }
146
- process.on('SIGTERM', cleanup)
147
- process.on('SIGINT', cleanup)
148
-
149
- async function post(to, body) {
150
- const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, {
151
- method: 'POST',
152
- headers: { 'content-type': 'application/json', 'x-peertable-token': token },
153
- body: JSON.stringify({ from: 'run-bridge', to, body }),
154
- })
155
- if (!res.ok) throw new Error(`room post ${res.status}`)
156
- }
157
-
158
- // cwd を project へ固定する。run ref は repo 相対なので、bridge がどこから起こされても解決する。
159
- // **絶対 path を渡すと公開 CLI が `INVALID_RUN_REF` で拒否する**(2026-08-09 の t11 で実測)。
160
- async function lattice(args) {
161
- const { stdout } = await run(latticeCli, args, { cwd: proj, maxBuffer: 8 * 1024 * 1024 })
162
- return JSON.parse(stdout)
163
- }
164
-
165
- const runSummaries = new Map() // run_ref -> 直近に記録した要約
166
- const interventions = new Map() // `${run_ref}\0${task_id}` -> 直近に観測した介入の形
167
- const closedRuns = new Set() // closed を観測した run。以後 poll しない
168
- let pollTicks = 0
169
- let readyRecorded = false
170
- function markReady() {
171
- if (readyRecorded) return
172
- const temp = `${record}.tmp`
173
- writeFileSync(temp, JSON.stringify({ ...JSON.parse(readFileSync(record, 'utf8')), ready_at: new Date().toISOString() }) + '\n')
174
- renameSync(temp, record)
175
- readyRecorded = true
176
- }
177
-
178
- // **どの run を見るかは `run list` から取る。** 旧版は order の worktree_path から run dir を
179
- // 切り出していたが、その order がもう出ないので、装置に聞く形へ変えた。
180
- // `selection: 'pull'` だけを対象にする——legacy automatic run は席が居ないので中継しても意味がない。
181
- async function pullRunRefs() {
182
- const listed = await lattice(['run', 'list', '--json'])
183
- markReady()
184
- return (listed.active_runs ?? [])
185
- .filter(entry => entry.selection === 'pull' && typeof entry.run_ref === 'string')
186
- .map(entry => entry.run_ref)
187
- .filter(ref => !closedRuns.has(ref))
188
- .sort()
189
- }
190
-
191
- function summarize(observation) {
192
- const intakes = observation.intakes ?? []
193
- const held = intakes.filter(entry => entry.intervention?.state === 'hold').map(entry => entry.task_id)
194
- const accepted = intakes.filter(entry => entry.accepted_head_sha !== null).map(entry => entry.task_id)
195
- const working = intakes.filter(entry => entry.accepted_head_sha === null).map(entry => entry.task_id)
196
- return `intake=[${working}] accepted=[${accepted}] hold=[${held}] closed=${observation.closed}`
197
- }
198
-
199
- // 介入は**その作業を始めた席**へ返す。誰が始めたかは観測が持っている(`intake.actor.agent`)ので、
200
- // bridge が Todo store の内部構造を読みに行く必要はない。
201
- function interventionText(runRef, intake) {
202
- const reason = intake.intervention?.reason ?? '(理由なし)'
203
- return [
204
- `[介入] ${intake.task_id} — ${intake.intervention?.state}`,
205
- `run: ${runRef}`,
206
- `理由: ${reason}`,
207
- `worktree: ${intake.worktree_path}`,
208
- '',
209
- '装置が競合を見て**留まれ**と言っている。作業を止めて room で調整するか、',
210
- `解消したら \`lattice run intake intervention --run ${runRef} --task ${intake.task_id}\` で読み直す。`,
211
- ].join('\n')
212
- }
213
-
214
- async function pollRuns() {
215
- let refs
216
- try { refs = await pullRunRefs() }
217
- catch (error) {
218
- // 観測できないことを黙らない。**沈黙を「異常なし」の証拠にしない**
219
- const detail = String(error?.stderr ?? error?.message ?? error).split('\n')[0].slice(0, 200)
220
- log(`run list 失敗: ${detail}`)
221
- return
222
- }
223
- for (const ref of refs) {
224
- let observation
225
- try { observation = await lattice(['run', 'observe', '--run', ref]) }
226
- catch (error) {
227
- const detail = String(error?.stderr ?? error?.message ?? error).split('\n')[0].slice(0, 200)
228
- const summary = `observe 失敗: ${detail}`
229
- // 1 run の失敗で他の run の報告を止めない
230
- if (runSummaries.get(ref) !== summary) { runSummaries.set(ref, summary); log(`${ref}: ${summary}`) }
231
- continue
232
- }
233
-
234
- // 介入は run 要約より先に返す。**留まれという指示が要約の後ろに埋もれない**ようにする
235
- for (const intake of observation.intakes ?? []) {
236
- const key = `${ref}\0${intake.task_id}`
237
- const shape = `${intake.intervention?.state}\0${intake.intervention?.reason ?? ''}`
238
- if (interventions.get(key) === shape) continue // 変化が無い間は鳴らさない
239
- interventions.set(key, shape)
240
- if (intake.intervention?.state !== 'hold') continue // none は静かに通す(通知は要らない)
241
- const seat = intake.actor?.agent
242
- if (typeof seat !== 'string' || seat.length === 0) {
243
- // broadcast は存在しない。宛先不明を黙らせず、local logへtypedに残す。
244
- log(`RUN_BRIDGE_RECIPIENT_UNKNOWN: 介入の宛先を決められない: ${ref} ${intake.task_id}`)
245
- continue
246
- }
247
- log(`介入: ${ref} ${intake.task_id} → ${seat}(${intake.intervention.reason ?? ''})`)
248
- await post(seat, interventionText(ref, intake))
249
- }
250
-
251
- const summary = summarize(observation)
252
- if (runSummaries.get(ref) !== summary) {
253
- runSummaries.set(ref, summary)
254
- log(`run 進行: ${ref} ${summary}`)
255
- }
256
- // closed は終端。最後の状態をlocal logへ記録してから外す
257
- if (observation.closed === true) {
258
- closedRuns.add(ref)
259
- log(`run 終端を観測したので poll を止める: ${ref}`)
260
- }
261
- }
262
- // 変化が無い時も1分に1回は件数を出す(沈黙を「異常なし」の証拠にしない)
263
- pollTicks += 1
264
- if (pollTicks % 6 === 1) log(`pull run ${refs.length} 件を観測中`)
265
- }
266
-
267
- log(`bridge start: room=${room} project=${proj} pid=${process.pid}(配車はしない・観測と中継だけ)`)
268
- for (;;) {
269
- try { await pollRuns() }
270
- catch (error) { log(`run 観測に失敗(続行する): ${error.message}`) }
271
- await sleep(10_000)
272
- }
@@ -1,21 +0,0 @@
1
- #!/usr/bin/env node
2
- // 着席 metadata を UTF-8 JSON で出す。python stdout の cp932 経由は日本語 roles を壊す。
3
- const [name, vendor, model, effort, sock, sess, rolesRaw, mission, aitermSessionId, tmuxNs] = process.argv.slice(2)
4
- const roles = String(rolesRaw || '').split(',').map((item) => item.trim()).filter(Boolean)
5
- const settings = { vendor, model }
6
- if (effort) settings.effort = effort
7
- const observe = { tmux_socket: sock, tmux_target: sess }
8
- if (tmuxNs) observe.tmux_namespace = tmuxNs
9
- const body = {
10
- name,
11
- vendor,
12
- model,
13
- role: roles[0] || '',
14
- roles,
15
- settings,
16
- aiterm_session_id: aitermSessionId,
17
- observe,
18
- }
19
- if (effort) body.effort = effort
20
- if (mission) body.mission = mission
21
- process.stdout.write(`${JSON.stringify(body)}\n`)