peertable 0.3.3 → 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/package.json +2 -1
- package/room/client.mjs +21 -7
- package/room/server.mjs +23 -7
- package/skill/SKILL.md +54 -0
- package/skill/scripts/archive-room-log.py +3 -1
- package/skill/scripts/run-bridge.mjs +601 -0
- package/skill/scripts/setup.sh +96 -2
- package/skill/scripts/teardown.sh +35 -0
- package/skill/scripts/wakeup-bridge.mjs +3 -1
- package/skill/templates/charter.md +4 -4
- package/skill/templates/member.md +35 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peertable",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "A round table of peer agents. No orchestrator at the head. Turn Claude Code sessions into a team of equal, long-lived peers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"room/client.mjs",
|
|
36
36
|
"room/Dockerfile",
|
|
37
37
|
"skill/",
|
|
38
|
+
"!skill/**/__pycache__/**",
|
|
38
39
|
"README.ja.md"
|
|
39
40
|
],
|
|
40
41
|
"engines": {
|
package/room/client.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
10
10
|
|
|
11
11
|
// client.mjs 側のハードコード版数。package.json の version と一致していることを
|
|
12
12
|
// diagnostics の version_consistency が見る(2 つの版数源の drift 検出。決定45)
|
|
13
|
-
const MCP_VERSION = '0.3.
|
|
13
|
+
const MCP_VERSION = '0.3.4'
|
|
14
14
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
15
|
|
|
16
16
|
const USAGE = `usage:
|
|
@@ -36,7 +36,10 @@ if (!URL_BASE || !ROOM || !ME) throw new Error('PEERTABLE_URL / PEERTABLE_ROOM /
|
|
|
36
36
|
|
|
37
37
|
const api = p => `${URL_BASE}/api/${ROOM}/${p}`
|
|
38
38
|
const headers = { 'Content-Type': 'application/json', ...(TOKEN ? { 'X-Peertable-Token': TOKEN } : {}) }
|
|
39
|
-
|
|
39
|
+
// 複数人宛は `to_names` を持つ(server が `to` を 'all' へ倒すので、旧 client でも取りこぼさない)。
|
|
40
|
+
// 新 client はここで実宛先だけを見るので、名指しされていない席は起きない。
|
|
41
|
+
const relevant = m => m.from !== ME
|
|
42
|
+
&& (Array.isArray(m.to_names) ? m.to_names.includes(ME) : (m.to === 'all' || m.to === ME))
|
|
40
43
|
|
|
41
44
|
let cursor = 0 // read_unread 用。参加時点から数える
|
|
42
45
|
|
|
@@ -47,7 +50,8 @@ const mcp = new Server(
|
|
|
47
50
|
instructions:
|
|
48
51
|
`あなたは Peertable room「${ROOM}」のメンバー「${ME}」である。` +
|
|
49
52
|
'<channel source="room"> の通知は「新着あり」の合図であり、本文は read_unread ツールで読む。' +
|
|
50
|
-
'発言は post ツール(to: "all" は全員宛、メンバー名で個別宛=DM
|
|
53
|
+
'発言は post ツール(to: "all" は全員宛、メンバー名で個別宛=DM、メンバー名の配列で複数人宛)。' +
|
|
54
|
+
'全員宛は1発言で全席1ターンを焼くので、用件が特定メンバーだけなら配列で名指しする。',
|
|
51
55
|
},
|
|
52
56
|
)
|
|
53
57
|
|
|
@@ -57,11 +61,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
57
61
|
tools: [
|
|
58
62
|
{
|
|
59
63
|
name: 'post',
|
|
60
|
-
description: 'room へ発言する。to は "all"
|
|
64
|
+
description: 'room へ発言する。to は "all"(全員宛)/ メンバー名(DM)/ メンバー名の配列(複数人宛)。'
|
|
65
|
+
+ '複数人に別々の用件があるとき、まとめて "all" にしない——名指しの配列にすれば、その人たちだけが起きる',
|
|
61
66
|
inputSchema: {
|
|
62
67
|
type: 'object',
|
|
63
68
|
properties: {
|
|
64
|
-
to: {
|
|
69
|
+
to: {
|
|
70
|
+
anyOf: [
|
|
71
|
+
{ type: 'string' },
|
|
72
|
+
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
73
|
+
],
|
|
74
|
+
description: '"all" / メンバー名 / メンバー名の配列。全員宛は決定・gate状態・全体記録だけに使う',
|
|
75
|
+
},
|
|
65
76
|
message: { type: 'string' },
|
|
66
77
|
},
|
|
67
78
|
required: ['to', 'message'],
|
|
@@ -73,7 +84,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
73
84
|
],
|
|
74
85
|
}))
|
|
75
86
|
|
|
76
|
-
const fmt = m => `[${m.seq}] ${m.from} → ${m.to} (${m.ts}): ${m.body}`
|
|
87
|
+
const fmt = m => `[${m.seq}] ${m.from} → ${Array.isArray(m.to_names) ? m.to_names.join(', ') : m.to} (${m.ts}): ${m.body}`
|
|
77
88
|
|
|
78
89
|
mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
79
90
|
const args = req.params.arguments ?? {}
|
|
@@ -140,7 +151,10 @@ async function subscribe() {
|
|
|
140
151
|
if (!relevant(m)) continue
|
|
141
152
|
await mcp.notification({
|
|
142
153
|
method: 'notifications/claude/channel',
|
|
143
|
-
params: {
|
|
154
|
+
params: {
|
|
155
|
+
content: `room に新着あり(${m.from} → ${Array.isArray(m.to_names) ? m.to_names.join(', ') : m.to})。read_unread で読むこと。`,
|
|
156
|
+
meta: { from: m.from, to: Array.isArray(m.to_names) ? m.to_names.join(',') : m.to, seq: String(m.seq) },
|
|
157
|
+
},
|
|
144
158
|
})
|
|
145
159
|
}
|
|
146
160
|
}
|
package/room/server.mjs
CHANGED
|
@@ -45,8 +45,21 @@ function readMessages(room, since = 0) {
|
|
|
45
45
|
.map(l => JSON.parse(l)).filter(m => m.seq > since)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// 宛先の正規化。複数人宛は `to_names` に持ち、`to` は 'all' へ倒す——旧 client は `to === 'all'` で
|
|
49
|
+
// 拾えるので、**取りこぼす側ではなく過剰に受け取る側へ倒れる**。宛先1人は従来どおり `to` だけで、
|
|
50
|
+
// 線の形が変わらない。
|
|
51
|
+
function normalizeAudience(to, toNames) {
|
|
52
|
+
const list = Array.isArray(to) ? to : Array.isArray(toNames) ? toNames : null
|
|
53
|
+
if (list === null) return { to: to ?? 'all', to_names: null }
|
|
54
|
+
if (!list.every(n => typeof n === 'string' && n.length > 0 && n !== 'all')) return { error: 'to_invalid' }
|
|
55
|
+
const names = [...new Set(list)]
|
|
56
|
+
if (names.length === 0) return { error: 'to_invalid' }
|
|
57
|
+
if (names.length === 1) return { to: names[0], to_names: null }
|
|
58
|
+
return { to: 'all', to_names: names }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function post(room, from, to, body, toNames = null) {
|
|
62
|
+
const msg = { seq: ++room.seq, ts: new Date().toISOString(), from, to, body, ...(toNames ? { to_names: toNames } : {}) }
|
|
50
63
|
appendFileSync(room.logPath, JSON.stringify(msg) + '\n')
|
|
51
64
|
const chunk = `data: ${JSON.stringify(msg)}\n\n`
|
|
52
65
|
for (const res of room.streams) res.write(chunk)
|
|
@@ -92,12 +105,14 @@ http.createServer(async (req, res) => {
|
|
|
92
105
|
return json(res, 403, { error: 'token_required' })
|
|
93
106
|
|
|
94
107
|
if (req.method === 'POST' && rest === 'messages') {
|
|
95
|
-
const { from, to, body: text } = JSON.parse(body)
|
|
108
|
+
const { from, to, to_names: toNames, body: text } = JSON.parse(body)
|
|
96
109
|
// 本文が無ければ **書かずに 400**。ここを素通しにすると `JSON.stringify` が欄ごと落として、
|
|
97
110
|
// append-only の正本へ**本文の無い行**が入る——しかも送信側には 200 と seq が返るので
|
|
98
111
|
// 「送れた」と表示される(2026-08-08 に本番で2件実測。消せない)
|
|
99
112
|
if (typeof text !== 'string') return json(res, 400, { error: 'body_required' })
|
|
100
|
-
|
|
113
|
+
const audience = normalizeAudience(to, toNames)
|
|
114
|
+
if (audience.error) return json(res, 400, { error: audience.error })
|
|
115
|
+
return json(res, 200, post(room, from, audience.to, text, audience.to_names))
|
|
101
116
|
}
|
|
102
117
|
if (req.method === 'POST' && rest === 'members') {
|
|
103
118
|
const { name, ...meta } = JSON.parse(body)
|
|
@@ -280,13 +295,14 @@ let last=null,recent=null
|
|
|
280
295
|
function render(m){
|
|
281
296
|
const at=new Date(m.ts)
|
|
282
297
|
if(m.from==='system'){const d=el('div','sys');d.appendChild(el('span','body',m.body));d.appendChild(stamp(at));logEl.appendChild(d);last=null;return}
|
|
283
|
-
const
|
|
284
|
-
const
|
|
298
|
+
const aud=m=>Array.isArray(m.to_names)?m.to_names.join(', '):m.to
|
|
299
|
+
const cont=last&&last.from===m.from&&aud(last)===aud(m)&&at-new Date(last.ts)<300000
|
|
300
|
+
const d=el('div','msg'+(aud(m)!=='all'?' dm':'')+(cont?' cont':''))
|
|
285
301
|
d.style.setProperty('--h',hue(m.from))
|
|
286
302
|
d.appendChild(el('div','av',initial(m.from)))
|
|
287
303
|
const body=el('div','body'),meta=el('div','meta')
|
|
288
304
|
meta.appendChild(el('span','who',m.from))
|
|
289
|
-
if(m
|
|
305
|
+
if(aud(m)!=='all')meta.appendChild(el('span','to','→ '+aud(m)))
|
|
290
306
|
meta.appendChild(stamp(at))
|
|
291
307
|
const bub=el('div','bubble');bub.appendChild(md(m.body))
|
|
292
308
|
body.appendChild(meta);body.appendChild(bub)
|
package/skill/SKILL.md
CHANGED
|
@@ -76,6 +76,60 @@ description: 任意プロジェクトに Peertable チーム(対等メンバ
|
|
|
76
76
|
|
|
77
77
|
各段は `[実施] / [スキップ] / [未実施]` を1行ずつ出す。**トークンを要するのは room 削除だけ**なので、そこが失敗しても残りの撤去は続行し、未実施を明示して非ゼロで終わる(黙って中断しない・決定58)。未実施が出ても**撤去そのものは済んでいる**。残りは表示された **[手当] の curl を手で叩く**だけで、`.team/` は既に消えているので **teardown.sh の再実行はできない**(2026-08-08 実測。再実行すると `setup-state.json` が読めず落ちる)。
|
|
78
78
|
|
|
79
|
+
## managed run 経由の配車(Lattice 併用モード・実行層へ載せる卓だけ)
|
|
80
|
+
|
|
81
|
+
卓を Lattice の実行層(managed run・隔離 worktree・実書き込み観測)へ載せた時は、task を claim で取り合わない。**task 選択=Lattice・候補席の選択=bridge・受けるかの決定=席**の3層で、`[配車]` は提示の可視化、席の `[受諾]` で初めて束縛が成立し、`[辞退]` なら別席へ再配車する。**席は動かさない**——自分の project に座ったまま、worktree へ絶対パスで出入りする。cwd と env を動かすと room 接続と MCP 解決が壊れるためで、これは回避策ではなく設計そのものである。
|
|
82
|
+
|
|
83
|
+
- **席と spool は接触しない。** 席が触るのは room と worktree だけで、`.lattice/` の直読み・直書き禁止の契約はそのまま。order は bridge が席の端末へ注入し、report は bridge が書く
|
|
84
|
+
- **席が出す room 語彙は3つだけ**(bridge が行頭一致で機械 parse する。いずれも**独立した1発言**): `[受諾] tN` / `[辞退] tN <理由>` / `[完了] tN`。辞退は正当な選択で、bridge が別の席へ再配車する
|
|
85
|
+
- **席の作法の正本は `templates/member.md` の「配車で来た仕事」節**(注入文の書式・禁止操作・scope 外書込・検証の回し方・成果の正本)。ここに二重化しない
|
|
86
|
+
- 前提は2つで、**どちらも立っていない卓には配車が来ない**(席の作法は無害に眠る): ①Lattice 側 executor adapter の登録と spool dir ②peertable 側の run-bridge 常駐
|
|
87
|
+
- **配車ブリッジの起動**: `PEERTABLE_POST_TOKEN=… nohup node scripts/run-bridge.mjs <project> <spool_dir> <席名>… > <project>/.team/run-bridge.log 2>&1 &`。停止は `node scripts/run-bridge.mjs <project> --stop`(**teardown.sh が自動で行う**)。起床ブリッジと同じ ADR 0157 の作法(pid 記録・起動時に前の記録を掃除・SIGTERM→SIGKILL)で、**席へは1バイトも送らない**——配車は room への投稿で届き、起こすのは channels(Claude 席)と wakeup-bridge(Codex 席)の仕事である
|
|
88
|
+
- **SSE が繋がって頭出しが済むまで配車しない。** 返事を聞けない状態で配車すると、直後の `[受諾]` を既読として捨てる(実測で踏んだ順序)
|
|
89
|
+
- 席が `[辞退]` したら別の席へ配車し直す。**`[受諾]` を受けて初めて report を書く**ので、辞退の窓は受諾より前にしかない(Lattice 側は受諾後の worker pid 変化を hard fail する)
|
|
90
|
+
|
|
91
|
+
運用側が踏みやすい所(実測で確認した挙動):
|
|
92
|
+
|
|
93
|
+
- **worktree は run 終端で `git worktree remove --force` される。** 席の commit は base_sha の子孫だが、木ごと消えた後はどの参照からも辿れない(gc の対象)。**成果の正本は Lattice が撮った observed diff** であって席の commit ではない。着地は run の外の工程で、`[完了]` は着地の宣言ではない
|
|
94
|
+
- **worktree には gitignore 済みの資産が無い**(`node_modules` 等)が、**席に install させない**。checkpoint 観測は `git status --ignored=matching` で撮る(gitignore 経由の scope 迂回を塞ぐ設計)ので、install した file が全部 `undeclared_write` になり、diff entry 上限 256 を超えた時点で観測ごと落ちる(実測: ignored 300本で `diff entry数が上限を超える`)。**依存は install 無しで解決する**——worktree が repo 配下(`<repo>/.lattice/runs/…/tree`)に切られるので、Node の bare specifier 解決が親を遡って canonical の `node_modules` に当たる(repo の外へ置くと `ERR_MODULE_NOT_FOUND`)。当たるのは canonical の版なので、lockfile を動かす task の検証結果は疑う。canonical tree で回させない——測りたい木ではない
|
|
95
|
+
- **`scope_writes` の外への書き込みは黙って弾かれず、`undeclared_write` として観測に出る。** 席へは「隠すな、room で言え」と伝わっている
|
|
96
|
+
|
|
97
|
+
## 線(共有プロトコル)を資源として宣言する(Lattice 併用モード)
|
|
98
|
+
|
|
99
|
+
**path が1つも重ならない2つの task が壊れ合うことがある。** 2026-08-08 の卓で実際に起きた: 片方が SSE のワイヤへ新しい event 種別を足した瞬間、そのストリームを読む側が壊れた。compile から見て完全に独立で、実際そう扱われていた。**依存は path ではなく共有プロトコルにあった。**
|
|
100
|
+
|
|
101
|
+
これを宣言できるのが**線**である。witness set を書く時、path・symbol の owns/reads/writes に加えて `lines` を書く(**省略可。省略=線の宣言なし**)。受理するのは witness set v5 / run_request v5 / boundary manifest v4 以降だけで、旧版へ書けば typed reject になる。
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
"lines": [
|
|
105
|
+
{
|
|
106
|
+
"line_id": "src.runtime-diff-observer.mjs--finding-kind",
|
|
107
|
+
"role": "writes",
|
|
108
|
+
"anchors": [
|
|
109
|
+
{ "kind": "path", "path": "src/runtime-diff-observer.mjs" },
|
|
110
|
+
{ "kind": "symbol", "name": "detectCheckpointFindings", "path": "src/runtime-diff-observer.mjs" }
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **`role`** は `writes`(線の形を変える側)か `reads`(その形に依存する側)。同じ `line_id` を別 task が持つ時、**`writes`×`reads` と `writes`×`writes` は直列化される。並列でいられるのは `reads`×`reads` だけ**——形を変える側が2人居るなら、その2人こそ揃えないと壊れるからである
|
|
117
|
+
- **`line_id` が一致した時だけ交差する。** 機械は anchor の重なりから「同じ線だろう」と推測しない——推測を装置に入れない設計であり、**綴りを揃える責任は宣言する側(AI)にある**
|
|
118
|
+
- **命名は錨から機械的に導く**: `anchors` **先頭**の repo-relative path の `/` を `.` に置換し、必要なら `--<種別>` を suffix する。`line_id` に使える文字は `[0-9A-Za-z._-]`(先頭は英数字・128文字まで)で、`/` も `:` も入らないのでこの置換が要る。**思いつきで名前を付けない**——揃わなければ交差は素通りする
|
|
119
|
+
- **名前を決めるのは最初に宣言した側だけ。** 後から同じ線を宣言する側は**再導出せず、既に在る `line_id` をそのまま写す**。錨が複数ある線で各自が「主たる錨」を選び直すと、同じ線に2つの名前が生まれて交差が消える
|
|
120
|
+
- **綴りが揃わなかった分は実行時が拾う。** 実際の変更 diff を錨の path へ近似して finding にし、その線の読み手を hold 閉包へ入れる。**計画時の宣言と実行時の観測の二段構え**であって、宣言だけで閉じる設計ではない。だから宣言漏れは致命ではないが、**漏れた分は「変更した後」にしか分からない**
|
|
121
|
+
- **錨は同じ repo の relative path だけ**(絶対 path は typed reject・`anchors` は最低1本)。越境 task(別 repo の file)を錨にすると**形式は通る**が、その path はこの repo に存在しないので**実行時の近似は永久に当たらない**。越境の線は「計画時の宣言としてだけ効く」と理解して使う(欠陥ではなく境界)
|
|
122
|
+
- **1つの task が同じ `line_id` を2本書くことはできない**(typed reject)。自分が writer でも reader でもある線は **`writes` を選ぶ**——読むだけの task はその形の変更を知る必要があり、それを教えられるのは writer 側の宣言だけだからである
|
|
123
|
+
|
|
124
|
+
**宣言する時の見つけ方**(席・親のどちらが witness を書く卓でも同じ):
|
|
125
|
+
|
|
126
|
+
1. 自分の変更が**他の誰かが読む形**を変えるかを問う: wire format・event 種別・schema の欄・CLI 出力の key・room の語彙・ファイル書式
|
|
127
|
+
2. 変えるなら `role: "writes"`、その形に依存して読むだけなら `role: "reads"`
|
|
128
|
+
3. 錨は「その形が書かれている file」。symbol 錨も足せるが、**symbol 錨も `path` 必須で、現在の実行時照合はその path 単位である**——同じ path の symbol を足しても近似は細かくならない(人が読む記録と、将来の照合のための宣言として足す)
|
|
129
|
+
4. **迷ったら宣言する。** 宣言は判定を厳しくするだけで、緩めることはできない
|
|
130
|
+
|
|
131
|
+
witness をどう生成するかは**対象 project 側の作法に従う**(Lattice repo なら `.lattice/todo/witness/<plan_key>.json` へ書いて `lattice todo independence compile --plan <key> --input <ref>`)。線はその witness の各 task entry へ足す欄であって、別の置き場を作らない。
|
|
132
|
+
|
|
79
133
|
## 親の operating notes(このセッションの振る舞い)
|
|
80
134
|
|
|
81
135
|
- 親は MCP を後付けできないため room へは HTTP API 直で参加する:
|
|
@@ -41,7 +41,9 @@ def main(argv):
|
|
|
41
41
|
body = m.get("body")
|
|
42
42
|
if body is None:
|
|
43
43
|
body = "(本文欠落——本文なし POST が着地した発言。欠落そのものを記録として残す)"
|
|
44
|
-
|
|
44
|
+
names = m.get("to_names")
|
|
45
|
+
audience = ", ".join(names) if isinstance(names, list) else m.get("to", "all")
|
|
46
|
+
lines += [f"## [{m['seq']}] {m['from']} → {audience} ・ {m['ts']}", "", body, ""]
|
|
45
47
|
with open(out, "w", encoding="utf-8") as f:
|
|
46
48
|
f.write("\n".join(lines))
|
|
47
49
|
return 0
|
|
@@ -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
|
+
}
|
package/skill/scripts/setup.sh
CHANGED
|
@@ -51,6 +51,39 @@ else
|
|
|
51
51
|
mode=lattice
|
|
52
52
|
fi
|
|
53
53
|
|
|
54
|
+
# Lattice 併用モードは、登録に使う公開CLIと同梱work-order binaryを、projectへ
|
|
55
|
+
# 何か置く前に確定する。通常はglobal installされた lattice の隣を使う。
|
|
56
|
+
# release前のsource treeを実測する時だけ、2つのenvで同じtreeのbinを明示できる。
|
|
57
|
+
lattice_cli=""
|
|
58
|
+
work_order_binary=""
|
|
59
|
+
node_binary=""
|
|
60
|
+
if [ "$mode" = "lattice" ]; then
|
|
61
|
+
lattice_cli="${LATTICE_CLI:-$(command -v lattice 2>/dev/null || true)}"
|
|
62
|
+
[ -n "$lattice_cli" ] || { echo "ERROR: lattice CLI が見つからない" >&2; exit 1; }
|
|
63
|
+
[ -x "$lattice_cli" ] || { echo "ERROR: lattice CLI が実行可能fileでない: $lattice_cli" >&2; exit 1; }
|
|
64
|
+
lattice_cli=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$lattice_cli")
|
|
65
|
+
|
|
66
|
+
work_order_binary="${LATTICE_WORK_ORDER_ADAPTER_BINARY:-$(dirname "$lattice_cli")/lattice-work-order-adapter.mjs}"
|
|
67
|
+
[ -f "$work_order_binary" ] && [ -x "$work_order_binary" ] || {
|
|
68
|
+
echo "ERROR: Lattice work-order adapter binary が見つからないか実行不能: $work_order_binary" >&2
|
|
69
|
+
exit 1
|
|
70
|
+
}
|
|
71
|
+
work_order_binary=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$work_order_binary")
|
|
72
|
+
node_binary=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.execPath))')
|
|
73
|
+
[ -x "$node_binary" ] || { echo "ERROR: Node executable が実行可能fileでない: $node_binary" >&2; exit 1; }
|
|
74
|
+
|
|
75
|
+
# config_refはgit root相対の公開契約。subdirectoryをprojectとして受けると別の
|
|
76
|
+
# `.lattice/` を作ってしまうので、黙って親repoへ登録せずtypedに止める。
|
|
77
|
+
project_root=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$proj")
|
|
78
|
+
git_root=$(git -C "$proj" rev-parse --show-toplevel 2>/dev/null || true)
|
|
79
|
+
[ -n "$git_root" ] || { echo "ERROR: Lattice 併用モードのprojectはgit repositoryでなければならない: $proj" >&2; exit 1; }
|
|
80
|
+
git_root=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$git_root")
|
|
81
|
+
[ "$project_root" = "$git_root" ] || {
|
|
82
|
+
echo "ERROR: project_dirはgit rootを指さなければならない: project=$project_root git_root=$git_root" >&2
|
|
83
|
+
exit 1
|
|
84
|
+
}
|
|
85
|
+
fi
|
|
86
|
+
|
|
54
87
|
mkdir -p "$tdir/roles"
|
|
55
88
|
cp "$tpl/charter.md" "$tdir/CLAUDE.md"
|
|
56
89
|
if [ "$mode" = "standalone" ]; then
|
|
@@ -92,6 +125,67 @@ fi
|
|
|
92
125
|
|
|
93
126
|
lattice_preexisting=false
|
|
94
127
|
[ -d "$proj/.lattice" ] && lattice_preexisting=true
|
|
128
|
+
runtime_preexisting=false
|
|
129
|
+
[ -d "$proj/.lattice/runtime" ] && runtime_preexisting=true
|
|
130
|
+
|
|
131
|
+
# adapter registry/config/spool はhost固有のruntime stateであり、sourceとして追跡しない。
|
|
132
|
+
# `.lattice/`の一部を正本として追跡するprojectでもruntimeだけをroot相対で除外する。
|
|
133
|
+
added_runtime_exclude=false
|
|
134
|
+
if [ "$mode" = "lattice" ] && [ -d "$proj/.git" ] \
|
|
135
|
+
&& ! grep -qx '/\.lattice/runtime/' "$proj/.git/info/exclude" 2>/dev/null; then
|
|
136
|
+
mkdir -p "$proj/.git/info"
|
|
137
|
+
echo '/.lattice/runtime/' >> "$proj/.git/info/exclude"
|
|
138
|
+
added_runtime_exclude=true
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# managed run の仕事口をLattice runtime stateとして用意する。configを`.team/`
|
|
142
|
+
# に置くとarchive teardownでregistryだけが残って壊れるため、registryと同じ
|
|
143
|
+
# `.lattice/runtime/`の寿命へ揃える。席はこのspoolへ直接触れない。
|
|
144
|
+
work_order_adapter=false
|
|
145
|
+
work_order_spool_ref=""
|
|
146
|
+
if [ "$mode" = "lattice" ]; then
|
|
147
|
+
work_order_root="$proj/.lattice/runtime/work-order-adapter"
|
|
148
|
+
work_order_spool="$work_order_root/spool"
|
|
149
|
+
work_order_config="$work_order_root/config.json"
|
|
150
|
+
work_order_registration="$tdir/work-order-adapter-registration.json"
|
|
151
|
+
work_order_config_ref=".lattice/runtime/work-order-adapter/config.json"
|
|
152
|
+
work_order_spool_ref=".lattice/runtime/work-order-adapter/spool"
|
|
153
|
+
|
|
154
|
+
mkdir -p "$work_order_spool/orders" "$work_order_spool/reports"
|
|
155
|
+
chmod 700 "$work_order_root" "$work_order_spool" "$work_order_spool/orders" "$work_order_spool/reports"
|
|
156
|
+
work_order_spool=$(node -e 'process.stdout.write(require("node:fs").realpathSync(process.argv[1]))' "$work_order_spool")
|
|
157
|
+
|
|
158
|
+
node -e '
|
|
159
|
+
const { writeFileSync } = require("node:fs");
|
|
160
|
+
const [target, spool] = process.argv.slice(1);
|
|
161
|
+
writeFileSync(target, `${JSON.stringify({
|
|
162
|
+
schema: "lattice.work_order_adapter_config.v1",
|
|
163
|
+
spool_dir: spool,
|
|
164
|
+
})}\n`, { mode: 0o600 });
|
|
165
|
+
' "$work_order_config" "$work_order_spool"
|
|
166
|
+
chmod 600 "$work_order_config"
|
|
167
|
+
node -e '
|
|
168
|
+
const { writeFileSync } = require("node:fs");
|
|
169
|
+
const [target, binary, script, configRef] = process.argv.slice(1);
|
|
170
|
+
writeFileSync(target, `${JSON.stringify({
|
|
171
|
+
schema: "lattice.runtime_adapter_registration_input.v2",
|
|
172
|
+
adapter_kind: "work-order",
|
|
173
|
+
launch_kind: "host_binary",
|
|
174
|
+
binary_path: binary,
|
|
175
|
+
argv: [script],
|
|
176
|
+
config_ref: configRef,
|
|
177
|
+
host_driven_epoch: true,
|
|
178
|
+
})}\n`, { mode: 0o600 });
|
|
179
|
+
' "$work_order_registration" "$node_binary" "$work_order_binary" "$work_order_config_ref"
|
|
180
|
+
chmod 600 "$work_order_registration"
|
|
181
|
+
|
|
182
|
+
(
|
|
183
|
+
cd "$proj"
|
|
184
|
+
"$lattice_cli" run adapter register --input "$work_order_registration"
|
|
185
|
+
)
|
|
186
|
+
work_order_adapter=true
|
|
187
|
+
echo "work-order adapter: binary=$node_binary argv=$work_order_binary config=$work_order_config_ref spool=$work_order_spool_ref" >&2
|
|
188
|
+
fi
|
|
95
189
|
|
|
96
190
|
# Lattice 併用モードだけ、工程表の右ペインへ円卓を差す(決定53・明示的コネクタ)。
|
|
97
191
|
# 公開URL基底は `PEERTABLE_PUBLIC_URL`(クオ環境: https://peertable.kitepon.dev)。
|
|
@@ -113,6 +207,6 @@ if [ ${#phases[@]} -gt 0 ]; then
|
|
|
113
207
|
phases_json="[${phases_json%,}]"
|
|
114
208
|
fi
|
|
115
209
|
|
|
116
|
-
printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":"%s","phases":%s,"added_exclude":%s,"lattice_preexisting":%s,"added_root_mcp":%s,"added_mcp_exclude":%s,"external_pane":%s,"project_json_preexisting":%s}\n' \
|
|
117
|
-
"$room" "$url" "$public_url" "$mode" "$plan" "$phases_json" "$added_exclude" "$lattice_preexisting" "$added_root_mcp" "$added_mcp_exclude" "$external_pane" "$project_json_preexisting" > "$tdir/setup-state.json"
|
|
210
|
+
printf '{"room":"%s","server_url":"%s","public_url":"%s","mode":"%s","plan_key":"%s","phases":%s,"added_exclude":%s,"lattice_preexisting":%s,"runtime_preexisting":%s,"added_runtime_exclude":%s,"added_root_mcp":%s,"added_mcp_exclude":%s,"external_pane":%s,"project_json_preexisting":%s,"work_order_adapter":%s,"work_order_spool_ref":"%s"}\n' \
|
|
211
|
+
"$room" "$url" "$public_url" "$mode" "$plan" "$phases_json" "$added_exclude" "$lattice_preexisting" "$runtime_preexisting" "$added_runtime_exclude" "$added_root_mcp" "$added_mcp_exclude" "$external_pane" "$project_json_preexisting" "$work_order_adapter" "$work_order_spool_ref" > "$tdir/setup-state.json"
|
|
118
212
|
echo "scaffold done: $tdir"
|
|
@@ -26,6 +26,9 @@ room=$(python3 -c "import json;print(json.load(open('$state'))['room'])")
|
|
|
26
26
|
url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])")
|
|
27
27
|
added=$(python3 -c "import json;print(json.load(open('$state'))['added_exclude'])")
|
|
28
28
|
lat_pre=$(python3 -c "import json;print(json.load(open('$state'))['lattice_preexisting'])")
|
|
29
|
+
runtime_pre=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('runtime_preexisting', True))")
|
|
30
|
+
added_runtime_ex=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_runtime_exclude', False))")
|
|
31
|
+
work_order_adapter=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('work_order_adapter', False))")
|
|
29
32
|
# 旧 state(added_root_mcp 不在・手動フォールバック時代の root_mcp_json_fallback)も読む
|
|
30
33
|
added_mcp=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_root_mcp', d.get('root_mcp_json_fallback', False)))")
|
|
31
34
|
added_mcp_ex=$(python3 -c "import json;d=json.load(open('$state'));print(d.get('added_mcp_exclude', d.get('root_mcp_json_fallback', False)))")
|
|
@@ -100,6 +103,30 @@ else
|
|
|
100
103
|
skip "seat-status-bridge(起動記録なし)"
|
|
101
104
|
fi
|
|
102
105
|
|
|
106
|
+
# 配車ブリッジ(managed run に載せた卓だけ立っている)。同じ理由で `.team/` を消す前に止める。
|
|
107
|
+
# 止め残すと、spool を見張り続ける常駐が次の run の order を拾って**卓が無いのに配車を投稿する**
|
|
108
|
+
if [ -f "$proj/.team/run-bridge.json" ]; then
|
|
109
|
+
if node "$(dirname "$0")/run-bridge.mjs" "$proj" --stop; then
|
|
110
|
+
did "run-bridge 停止"
|
|
111
|
+
else
|
|
112
|
+
miss "run-bridge 停止に失敗(常駐が残る)— 上の _STOP_FAILED を見て手で止める"
|
|
113
|
+
fi
|
|
114
|
+
else
|
|
115
|
+
skip "run-bridge(起動記録なし)"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# setupが新しく作ったhost固有runtimeだけを撤去する。既存runtimeは他adapterや進行中runの
|
|
119
|
+
# 所有物を含み得るので触らない。runtimeを先に消してからexcludeを戻し、teardown後に
|
|
120
|
+
# untracked stateが露出する順序逆転を防ぐ。
|
|
121
|
+
if yes_ "$work_order_adapter" && ! yes_ "$runtime_pre"; then
|
|
122
|
+
rm -rf "$proj/.lattice/runtime"
|
|
123
|
+
did ".lattice/runtime/ 撤去(setup が新規作成したhost固有state)"
|
|
124
|
+
elif yes_ "$work_order_adapter"; then
|
|
125
|
+
skip ".lattice/runtime/(setup 以前から存在)"
|
|
126
|
+
else
|
|
127
|
+
skip ".lattice/runtime/(work-order adapter登録なし)"
|
|
128
|
+
fi
|
|
129
|
+
|
|
103
130
|
# 外部ペイン(決定53)。`.team/` を消す前に戻す——退避先が `.team/` の中にある
|
|
104
131
|
ext=$(python3 -c "import json;print(json.load(open('$state')).get('external_pane', False))")
|
|
105
132
|
pj_pre=$(python3 -c "import json;print(json.load(open('$state')).get('project_json_preexisting', False))")
|
|
@@ -179,6 +206,14 @@ else
|
|
|
179
206
|
skip "exclude の /.mcp.json(setup が足していない)"
|
|
180
207
|
fi
|
|
181
208
|
|
|
209
|
+
if yes_ "$added_runtime_ex"; then
|
|
210
|
+
grep -vx '/\.lattice/runtime/' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
|
|
211
|
+
mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
|
|
212
|
+
did "exclude から /.lattice/runtime/ を撤去"
|
|
213
|
+
else
|
|
214
|
+
skip "exclude の /.lattice/runtime/(setup が足していない)"
|
|
215
|
+
fi
|
|
216
|
+
|
|
182
217
|
if yes_ "$added"; then
|
|
183
218
|
grep -vx '\.team/' "$proj/.git/info/exclude" > "$proj/.git/info/exclude.tmp" || true
|
|
184
219
|
mv "$proj/.git/info/exclude.tmp" "$proj/.git/info/exclude"
|
|
@@ -91,7 +91,9 @@ setInterval(async () => {
|
|
|
91
91
|
function dispatch(msg) {
|
|
92
92
|
for (const seat of seats) {
|
|
93
93
|
if (msg.from === seat) continue
|
|
94
|
-
|
|
94
|
+
// 複数人宛は `to_names` が実宛先を持つ(server は旧 client のために `to` を 'all' へ倒す)。
|
|
95
|
+
// ここで実宛先を見ないと、名指しされていない Codex 席まで起こしてしまう。
|
|
96
|
+
if (Array.isArray(msg.to_names) ? !msg.to_names.includes(seat) : (msg.to !== 'all' && msg.to !== seat)) continue
|
|
95
97
|
pending.get(seat).push(msg)
|
|
96
98
|
}
|
|
97
99
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# チーム憲章
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
これはチーム作業である。自分が引き受けたタスクの完了はミッションの完了ではない。全タスク完了までチームは解散しない。
|
|
4
4
|
|
|
5
5
|
1. 拘束力を持つのは**工程正本への記録**と room での決定だけ(工程正本=Lattice 併用モードなら Lattice の todo 記録、単独円卓モードなら room の宣言そのもの)。DM(個別宛)で決まったことは決定ではない。部位を跨ぐ合意・設計判断は必ず room 全員宛で行う
|
|
6
|
-
2. 進捗は room に一行で報告する: claim
|
|
7
|
-
3. claim
|
|
6
|
+
2. 進捗は room に一行で報告する: managed run なら配車への受諾・辞退、その他の卓なら claim・join、共通して完了時・詰まり自覚時・方針変更時。task 外でも成果物になる作業(実測・検証・CI)は着手前に一言。仲間を五里霧中に置かない
|
|
7
|
+
3. 仕事を引き受ける手順は実行方式で分かれる。managed run では task を Lattice、候補席を bridge が選び、`[配車] <タスク> → <席>` はその提示を全員へ見せるだけで割当ではない。提示された席が `[受諾] <タスク>` を返した時だけ束縛が成立し、`[辞退] <タスク> <理由>` なら bridge が別席へ再配車する。この経路で `[claim]` を重ねない。managed run でない卓では、room へ `[claim] <タスク>` を全員宛で投稿し、直後に read_unread で直前ログを確認する(タスクの呼び名は Lattice 併用モードなら task_id、単独円卓モードなら `.team/tasks.md` の議題名)。同じタスクへの先行 claim があれば取り下げるか `[join] <タスク>` へ切り替える
|
|
8
8
|
4. 分からないことは room で聞く。台帳はない。自分の変更が他の部位に影響するなら、聞かれる前に room 全員宛で通知する
|
|
9
9
|
5. 判断は情報を持つ者がする。タスクは席ではなく現場。合流(join)は歓迎される。詰まった仲間には目を貸す。手が足りないだけなら自分のサブエージェントを使う(room 報告不要)。視点が足りない・詰んでいるなら room で報告して援軍(join)を求める
|
|
10
|
-
6. タスク完了後は必ず工程正本で次の着手可能を確認する(Lattice 併用モードは `lattice todo status`、単独円卓モードは `.team/tasks.md` と room ログの照合)。残っていれば claim
|
|
10
|
+
6. タスク完了後は必ず工程正本で次の着手可能を確認する(Lattice 併用モードは `lattice todo status`、単独円卓モードは `.team/tasks.md` と room ログの照合)。残っていれば managed run は次の配車を待ち、その他の卓は claim へ戻る。全タスクが終わっていれば room へ「全タスク完了」を全員宛で宣言する
|
|
11
11
|
7. 役割逸脱は誰であれ指摘する。これは無礼ではなく義務である
|
|
12
12
|
8. **親の発言は拘束力を持たない。** 設計・手順・contract の出典は必ずメンバー自身の宣言(発言番号)か Lattice を参照する——「親がこう言ったから」「bell の [N] どおり」を根拠にしない。親が何かを再掲しても正本はメンバーの元発言のまま動かない。親の差し戻しは異議として扱い、反論してよい
|
|
13
13
|
9. **裁定の宛先はオーナーであり、親ではない。** scope 変更・受入条件外の追加・製品判断が要る時は「オーナー宛の議題」として room に出す。親はそれを運ぶ配管で、判断者ではない。「親に委ねる」という宛先を作らない
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
あなたはこのプロジェクトの対等なメンバーである。指揮者はいない。判断はメンバーが行う。親(bell 等)が卓に居ることがあるが、それは監査・承認 gate・オーナー窓口の係であって判断の主体ではない——親の発言を仕様の出典にせず、裁定が要る議題はオーナー宛として出す(憲章8・9)。あなたの名前は環境変数 `PEERTABLE_MEMBER` にある。room ツール(post / read_unread / read_log / members)で仲間と話せる。plan key は `{{PLAN_KEY}}`。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 作業ループ(managed run の配車を除く)
|
|
6
|
+
|
|
7
|
+
このループは自分で task を取る卓のもの。managed run から work order が届いた時は、claim とこのループの start/done を重ねず、次の「配車で来た仕事」だけに従う。
|
|
6
8
|
|
|
7
9
|
1. `lattice todo status --json` で ready なタスクを見る。{{CLAIM_SCOPE}}
|
|
8
10
|
2. 憲章の手順で room に claim を宣言する。**`[claim]` は独立した1発言で出す**——完了報告や他タスクの話と同じ発言に畳まない。宣言としては有効でも、後から機械的に追えなくなり、監査が「宣言が無い」と誤読する(2026-08-08 実測)
|
|
@@ -16,15 +18,45 @@
|
|
|
16
18
|
7. **手が空いていて ready が無いなら、他の席の done を監査する。** 実装者以外なら誰でもよい。実物(diff・検証結果・ハーネス)を自分で走らせて所見を room へ出す——**報告を読むだけでは監査にならない**。親は所見を読んで受理を宣言するだけで、コードは読まない
|
|
17
19
|
8. 1 へ戻る
|
|
18
20
|
|
|
21
|
+
## 配車で来た仕事(Lattice の managed run に載っている卓だけ)
|
|
22
|
+
|
|
23
|
+
卓が Lattice の実行層(managed run)に載っている時は、仕事は claim で取り合わず、**task 選択=Lattice・候補席の選択=bridge・受けるかの決定=席**の3層で届く。bridge の全員宛 `[配車] t7 → akari` は提示を可視化しただけで、まだ割当ではない。提示された席が `[受諾] t7` を返した時だけ、その席と work order の束縛が成立する。届き方は自分宛の次の1ブロックである。載っていない卓では配車は起きないので、この節は静かに眠る。
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
[work order] t7
|
|
27
|
+
worktree: /abs/path/to/.lattice/runs/<run>/worktrees/<id>/tree
|
|
28
|
+
base_sha: 8f0e…(40 hex)
|
|
29
|
+
scope_writes: src/a.mjs, test/a.test.mjs
|
|
30
|
+
verifier_refs: node --test test/a.test.mjs
|
|
31
|
+
forbidden_operations: push, branch, merge, rebase, reset, stash
|
|
32
|
+
packet_digest: 3a91…(64 hex)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
1. **work order の7欄(task・worktree・base・scope・verifier・禁止操作・packet digest)を読んで、受諾か辞退を即返す。** `[受諾] t7` / `[辞退] t7 <理由>` を**独立した1発言**で room 全員宛へ。bridge は行頭一致で機械 parse するので、他の話と同じ発言に畳むと届かない。**辞退は正当な選択**(本筋の WIP が塞がっている・自分の測定器ではその検証ができない)で、bridge が別の席へ再配車する。黙殺だけはしない
|
|
36
|
+
2. **この work order に `[claim]` や `lattice todo start` を重ねて割当を作らない。** task の選択は Lattice、席の選択は bridge、席との束縛は `[受諾]`、実行結果の正本は run の event/receipt がすでに持つ。別に campaign の todo を閉じる手続きがある時は、receipt を確認した後にその手続きとして行うのであって、配車の受諾を二重記録するためではない
|
|
37
|
+
3. **worktree の中だけを、絶対パスで触る。** `cd` しない・env を書き換えない・別 project へ移らない。席の room 接続と MCP 解決は cwd と env に乗っているので、動かすと卓から落ちる(そのために席を動かさない設計になっている)。git は `git -C <worktree> …`、編集は絶対パスで開く
|
|
38
|
+
4. **commit してよい。禁止は `push` / `branch` / `merge` / `rebase` / `reset` / `stash` の6つ**で、正は注入文の `forbidden_operations`(出所は Lattice engine が packet へ載せる実物・`src/runtime-engine.mjs` の `FORBIDDEN_OPERATIONS`)。worktree は `base_sha` の detached HEAD なので、そこへ積む commit は base の子孫のままで canonical branch を動かさない。逆に6つは HEAD を base の子孫から外すか、外部へ効果を出す操作なので、観測の前提か公開契約のどちらかを壊す
|
|
39
|
+
5. **`scope_writes` の外へ書いても黙って弾かれない——`undeclared_write` として観測に出る。** 書く必要があると分かった時点で room へ言う。隠して書いても diff で見えるだけである
|
|
40
|
+
6. **検証は worktree の中で回す。** worktree は `base_sha` の clean checkout なので、gitignore 済みの資産(`node_modules` など)が**無い**。埋めに行く前に次の3点を読むこと。
|
|
41
|
+
- **worktree の中で `npm install` してはいけない。** checkpoint 観測は `git status --ignored=matching` で撮る=**gitignore 済みの書き込みも拾う**(gitignore 経由の scope 迂回を塞ぐ設計)。install した file はそのまま `undeclared_write` になり、diff entry 上限(256)を超えた時点で**観測そのものが失敗する**。自分の task の記録を自分で壊すことになる
|
|
42
|
+
- **依存は install しなくても解決する。** worktree は repo 配下(`<repo>/.lattice/runs/…/tree`)に切られるので、Node の bare specifier 解決が親ディレクトリを遡って canonical の `node_modules` に当たる(repo の外に置かれた木では当たらない)。これは現在の worktree 配置がもたらしている便益であって、どこでも成り立つ性質ではない
|
|
43
|
+
- **当たるのは canonical に入っている版である。** worktree の `package.json` が要求する版とは限らないので、**lockfile や依存を動かす task では、検証結果を「解決された版のずれ」ごと疑う**
|
|
44
|
+
- それでも回らない検証は、無理に回さず room で言う。**動かないからといって canonical tree で回さない**——それは測りたい木ではない
|
|
45
|
+
7. 終わったら **`[完了] t7` を独立した1発言**で room 全員宛へ。bridge がこれを見て report を書き、Lattice が worktree の diff を独立に撮って receipt にする
|
|
46
|
+
|
|
47
|
+
**成果の正本は席の commit ではなく、Lattice が撮った observed diff である。** `[完了]` の後、Lattice が worktree の diff を独立に撮って receipt にする——**受理されるのはその観測であって、あなたの commit ではない**。
|
|
48
|
+
|
|
49
|
+
worktree は最後に `git worktree remove --force` で畳まれ、木ごと消える(commit object は残るが、どの参照からも辿れない=gc の対象)。**畳まれるのは `run close` の時ではなく、supervisor が終了する時である**——close は run を閉じるだけで成果を捨てない(木そのものが run の成果なので、着地させる前に消さない設計)。**それでも「commit したから残る」と思わないこと。** canonical への着地は run の外の別工程であり、`[完了]` も `run close` も着地の宣言ではない。
|
|
50
|
+
|
|
19
51
|
## 再着任(context が要約されたら)
|
|
20
52
|
|
|
21
|
-
自分の context が要約された(=会話の前半が手元に無い)と気づいたら、実装を続ける前に `.team/roles/member.md` と `.team/CLAUDE.md` を読み直して着任し直し、room へ `[再着任] <名前>`
|
|
53
|
+
自分の context が要約された(=会話の前半が手元に無い)と気づいたら、実装を続ける前に `.team/roles/member.md` と `.team/CLAUDE.md` を読み直して着任し直し、room へ `[再着任] <名前>` を一行投稿する。進行中の仕事は自分の記憶でなく**工程正本で取り直す**——managed run なら `run observe` の状態と room の `[配車]` / `[受諾]` / `[辞退]` / `[完了]`、その他の Lattice 併用卓なら `lattice todo status --json` の active と room の claim・完了報告を照合する。記憶と正本が食い違ったら、正本を正として食い違いを room で報告する。
|
|
22
54
|
|
|
23
55
|
## 注意
|
|
24
56
|
|
|
25
57
|
- Lattice の書き込みが `STORE_WRITE_CONFLICT` 等で弾かれたら、1〜2 秒待って同じコマンドを再実行する(同時書込の正常な負け方であり、壊れてはいない)
|
|
26
58
|
- `--parallel-frontier` を付けた start が `parallel_frontier_not_applicable` で弾かれたら、それは**その task がもう `next_ready` に居ない**(他人が着手済み・依存で塞がった)という意味である。フラグの不具合ではないので付け外しで粘らず、`lattice todo status --json` と room ログで claim 状況を確認し直す
|
|
27
|
-
- claim が衝突したら、Lattice の start 記録(誰が in-progress
|
|
59
|
+
- managed run でない卓の claim が衝突したら、Lattice の start 記録(誰が in-progress か)を機械の事実として使う。managed run は claim 衝突で裁定せず、bridge の提示と席の受諾・辞退を見る
|
|
28
60
|
- **note が持つものを room の散文へ二重化しない**。設計メモ・タスク固有の経緯は `lattice todo note` に置き、room には決定と進捗だけを流す
|
|
29
61
|
- room の新着通知が来たら read_unread で読む。返事が要るものには post で応える
|
|
30
62
|
- **Codex 席の場合**: 起床は channels ではなく wakeup-bridge が担う。`room に新着あり(<誰> → <宛先>)。read_unread で読むこと。` が端末へ直接届くので、Claude 席と同じく read_unread で読む。**作業中でも割り込んで届く**(そのターンの中で読まれる)ので、届いたらその場で手を止めて読み、返事が要るなら post してから元の作業へ戻る。自分の発言では起きない
|