peertable 0.3.3 → 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 +2 -1
- package/room/client.mjs +23 -9
- package/room/server.mjs +93 -18
- package/skill/SKILL.md +89 -15
- package/skill/scripts/archive-room-log.py +3 -1
- 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 +259 -0
- package/skill/scripts/seat-status-bridge.mjs +34 -14
- package/skill/scripts/seat-usage.mjs +26 -0
- package/skill/scripts/setup.sh +49 -2
- package/skill/scripts/teardown.sh +139 -1
- package/skill/scripts/wakeup-bridge.mjs +5 -4
- package/skill/templates/charter.md +6 -6
- package/skill/templates/done.sh +114 -5
- package/skill/templates/member-standalone.md +7 -3
- package/skill/templates/member.md +100 -6
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 明示DMの本人要請を確認し、親が席を新effortで再起動する。
|
|
3
|
+
# usage: change-effort.sh <project_dir> <member> <effort> [parent_name]
|
|
4
|
+
#
|
|
5
|
+
# 会話contextは引き継がない。作業状態はroomログ・工程正本・gitから再着任で回収する。
|
|
6
|
+
# busy席は止めず、起動失敗時は旧effortでの再起動を1回だけ明示rollbackする。
|
|
7
|
+
set -eu
|
|
8
|
+
|
|
9
|
+
proj="${1:-}"; name="${2:-}"; effort="${3:-}"; parent="${4:-bell}"
|
|
10
|
+
[ -n "$proj" ] && [ -n "$name" ] && [ -n "$effort" ] || {
|
|
11
|
+
echo "EFFORT_CHANGE_ARGS_INVALID: usage: change-effort.sh <project_dir> <member> <effort> [parent_name]" >&2
|
|
12
|
+
exit 2
|
|
13
|
+
}
|
|
14
|
+
case "$name:$parent" in
|
|
15
|
+
*[!A-Za-z0-9._:-]*) echo "EFFORT_CHANGE_ARGS_INVALID: member/parent名に使えない文字がある" >&2; exit 2 ;;
|
|
16
|
+
esac
|
|
17
|
+
|
|
18
|
+
state="$proj/.team/setup-state.json"
|
|
19
|
+
[ -f "$state" ] || { echo "EFFORT_CHANGE_STATE_MISSING: $state" >&2; exit 1; }
|
|
20
|
+
read -r room url <<EOF
|
|
21
|
+
$(python3 -c "import json;d=json.load(open('$state'));print(d['room'],d['server_url'])")
|
|
22
|
+
EOF
|
|
23
|
+
|
|
24
|
+
if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then
|
|
25
|
+
. "$HOME/.config/peertable.env"
|
|
26
|
+
fi
|
|
27
|
+
[ -n "${PEERTABLE_POST_TOKEN:-}" ] || { echo "EFFORT_CHANGE_TOKEN_MISSING" >&2; exit 1; }
|
|
28
|
+
|
|
29
|
+
members=$(curl -sf "$url/api/$room/members") || {
|
|
30
|
+
echo "EFFORT_CHANGE_ROOM_UNREACHABLE: membersを読めない" >&2; exit 1;
|
|
31
|
+
}
|
|
32
|
+
meta=$(printf '%s' "$members" | python3 -c '
|
|
33
|
+
import json,sys
|
|
34
|
+
name=sys.argv[1]
|
|
35
|
+
member=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),None)
|
|
36
|
+
if not member or member.get("vendor") not in ("claude","codex") or not member.get("model"):
|
|
37
|
+
raise SystemExit(1)
|
|
38
|
+
print("\t".join((member["vendor"],member["model"],member.get("effort") or "")))
|
|
39
|
+
' "$name") || { echo "EFFORT_CHANGE_MEMBER_METADATA_MISSING: ${name} のvendor/modelが要る" >&2; exit 1; }
|
|
40
|
+
IFS=$'\t' read -r vendor model old_effort <<EOF
|
|
41
|
+
$meta
|
|
42
|
+
EOF
|
|
43
|
+
|
|
44
|
+
case "$vendor" in
|
|
45
|
+
claude)
|
|
46
|
+
case "$effort" in low|medium|high|xhigh|max) ;; *)
|
|
47
|
+
echo "EFFORT_CHANGE_UNSUPPORTED: claude/${model} は low|medium|high|xhigh|max" >&2; exit 1 ;;
|
|
48
|
+
esac
|
|
49
|
+
;;
|
|
50
|
+
codex)
|
|
51
|
+
catalog=$(codex debug models 2>/dev/null) || {
|
|
52
|
+
echo "EFFORT_CHANGE_MODEL_CATALOG_UNAVAILABLE: codex debug models" >&2; exit 1;
|
|
53
|
+
}
|
|
54
|
+
if ! printf '%s' "$catalog" | python3 -c '
|
|
55
|
+
import json,sys
|
|
56
|
+
model,effort=sys.argv[1:3]
|
|
57
|
+
entry=next((m for m in json.load(sys.stdin).get("models",[]) if m.get("slug")==model),None)
|
|
58
|
+
levels=[] if entry is None else [x.get("effort") for x in entry.get("supported_reasoning_levels",[])]
|
|
59
|
+
raise SystemExit(0 if effort in levels else 1)
|
|
60
|
+
' "$model" "$effort"; then
|
|
61
|
+
echo "EFFORT_CHANGE_UNSUPPORTED: codex/${model} は effort=${effort} をcatalogで提供していない" >&2
|
|
62
|
+
exit 1
|
|
63
|
+
fi
|
|
64
|
+
;;
|
|
65
|
+
esac
|
|
66
|
+
|
|
67
|
+
messages=$(curl -sf "$url/api/$room/messages") || {
|
|
68
|
+
echo "EFFORT_CHANGE_ROOM_UNREACHABLE: messagesを読めない" >&2; exit 1;
|
|
69
|
+
}
|
|
70
|
+
request_seq=$(printf '%s' "$messages" | python3 -c '
|
|
71
|
+
import json,sys
|
|
72
|
+
name,parent,effort=sys.argv[1:4]
|
|
73
|
+
rows=json.load(sys.stdin).get("messages",[])
|
|
74
|
+
def addressed(row,target):
|
|
75
|
+
return row.get("to")==target or target in row.get("to_names",[])
|
|
76
|
+
def exact_dm(row,target):
|
|
77
|
+
return row.get("to")==target and not row.get("to_names")
|
|
78
|
+
requests=[r for r in rows if r.get("from")==name and exact_dm(r,parent)
|
|
79
|
+
and r.get("body")==f"[effort変更依頼] {effort}"]
|
|
80
|
+
if not requests: raise SystemExit(1)
|
|
81
|
+
req=max(requests,key=lambda r:r.get("seq",0))
|
|
82
|
+
marker="request #{}".format(req.get("seq",0))
|
|
83
|
+
completed=[r for r in rows if r.get("from")==parent and addressed(r,name)
|
|
84
|
+
and str(r.get("body","")).startswith("[effort変更]")
|
|
85
|
+
and marker in str(r.get("body",""))]
|
|
86
|
+
if completed and max(r.get("seq",0) for r in completed)>=req.get("seq",0): raise SystemExit(1)
|
|
87
|
+
print(req["seq"])
|
|
88
|
+
' "$name" "$parent" "$effort") || {
|
|
89
|
+
echo "EFFORT_CHANGE_REQUEST_REQUIRED: ${name} → ${parent} の『[effort変更依頼] ${effort}』新着DMが要る" >&2
|
|
90
|
+
exit 1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
sock="${PEERTABLE_TMUX_SOCKET:-${TMPDIR:-/tmp/}claude-tmux-sockets/claude.sock}"
|
|
94
|
+
sess="peer-$name"
|
|
95
|
+
tmux -S "$sock" has-session -t "$sess" 2>/dev/null || {
|
|
96
|
+
echo "EFFORT_CHANGE_SEAT_MISSING: ${sess}" >&2; exit 1;
|
|
97
|
+
}
|
|
98
|
+
screen=$(tmux -S "$sock" capture-pane -t "$sess" -p -S -25 2>/dev/null) || {
|
|
99
|
+
echo "EFFORT_CHANGE_SEAT_UNREADABLE: $sess" >&2; exit 1;
|
|
100
|
+
}
|
|
101
|
+
case "$screen" in
|
|
102
|
+
*"esc to interrupt"*) echo "EFFORT_CHANGE_SEAT_BUSY: ${sess} は処理中。本人がidleになってから再実行する" >&2; exit 1 ;;
|
|
103
|
+
esac
|
|
104
|
+
|
|
105
|
+
launch="$(dirname "$0")/launch-seat.sh"
|
|
106
|
+
brief="effortが${effort}へ変更され、席を再起動しました。.team/roles/member.mdと工程正本・roomログから再着任し、進行中taskを続けてください。"
|
|
107
|
+
if ! "$launch" "$proj" "$name" "$model" "$vendor" "$effort" "$brief"; then
|
|
108
|
+
echo "EFFORT_CHANGE_RESTART_FAILED: effort=${effort}。旧effort=${old_effort:-default}へrollbackする" >&2
|
|
109
|
+
rollback_brief="effort変更に失敗して旧設定へrollbackしました。.team/roles/member.mdと工程正本・roomログから再着任してください。"
|
|
110
|
+
if "$launch" "$proj" "$name" "$model" "$vendor" "$old_effort" "$rollback_brief"; then
|
|
111
|
+
echo "EFFORT_CHANGE_ROLLED_BACK: ${name} は旧effort=${old_effort:-default}で再着席" >&2
|
|
112
|
+
else
|
|
113
|
+
echo "EFFORT_CHANGE_ROLLBACK_FAILED: ${name} の席を手動で復旧する必要がある" >&2
|
|
114
|
+
fi
|
|
115
|
+
exit 1
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
members_after=$(curl -sf "$url/api/$room/members") || {
|
|
119
|
+
echo "EFFORT_CHANGE_CHANGED_BUT_UNVERIFIED: 席は再起動済み、membersを読めない" >&2; exit 1;
|
|
120
|
+
}
|
|
121
|
+
if ! printf '%s' "$members_after" | python3 -c '
|
|
122
|
+
import json,sys
|
|
123
|
+
name,effort=sys.argv[1:3]
|
|
124
|
+
m=next((m for m in json.load(sys.stdin).get("members",[]) if m.get("name")==name),{})
|
|
125
|
+
raise SystemExit(0 if m.get("effort")==effort else 1)
|
|
126
|
+
' "$name" "$effort"; then
|
|
127
|
+
echo "EFFORT_CHANGE_CHANGED_BUT_UNVERIFIED: 席は再起動済み、member metadataがeffort=${effort}でない" >&2
|
|
128
|
+
exit 1
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
old_label="${old_effort:-default}"
|
|
132
|
+
history=$(python3 -c 'import json,sys;print(json.dumps({"from":sys.argv[1],"to":sys.argv[2],"body":f"[effort変更] {sys.argv[1]} が {sys.argv[2]} の effort を {sys.argv[3]} → {sys.argv[4]} に変更(席を再起動 / request #{sys.argv[5]})"},ensure_ascii=False))' "$parent" "$name" "$old_label" "$effort" "$request_seq")
|
|
133
|
+
if ! curl -sf -o /dev/null -X POST "$url/api/$room/messages" \
|
|
134
|
+
-H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' -d "$history"; then
|
|
135
|
+
echo "EFFORT_CHANGE_CHANGED_BUT_HISTORY_FAILED: ${name} はeffort=${effort}で再着席済み、room履歴の記録に失敗" >&2
|
|
136
|
+
exit 1
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
echo "EFFORT_CHANGE_OK: ${name} ${old_label} → ${effort}(request #${request_seq} / parent=${parent})"
|
|
@@ -17,6 +17,10 @@ state="$proj/.team/setup-state.json"
|
|
|
17
17
|
read -r room url mode plan <<EOF
|
|
18
18
|
$(python3 -c "import json;d=json.load(open('$state'));print(d['room'],d['server_url'],d['mode'],d.get('plan_key') or '-')")
|
|
19
19
|
EOF
|
|
20
|
+
# setup が解決した CLI の実 path。**席が PATH の `lattice` へ逸れないため**に渡す
|
|
21
|
+
# (release 前の source tree では、PATH の install は pull 系 command を持たない)。
|
|
22
|
+
# 無い卓(旧 setup-state)では空になり、席は既定どおり `lattice` を使う。
|
|
23
|
+
lattice_cli=$(python3 -c "import json;print(json.load(open('$state')).get('lattice_cli') or '')")
|
|
20
24
|
|
|
21
25
|
if [ -z "${PEERTABLE_POST_TOKEN:-}" ] && [ -f "$HOME/.config/peertable.env" ]; then
|
|
22
26
|
. "$HOME/.config/peertable.env"
|
|
@@ -24,6 +28,11 @@ fi
|
|
|
24
28
|
|
|
25
29
|
# 前の卓の残骸を回収してから立てる(同名セッションが残ると起動が黙って古い席に化ける)
|
|
26
30
|
tmux -S "$sock" kill-session -t "$sess" 2>/dev/null || true
|
|
31
|
+
|
|
32
|
+
# 素性記録(.team/seats/<name>.json)の掃除は**ここ**でやる。席を起こす経路は全席が必ず通るので、
|
|
33
|
+
# 死んだ記録がここで必ず消える(ADR 0157)。teardown や人が叩くコマンドに置くと、誰も叩かず溜まる。
|
|
34
|
+
# **消すのは同名の自分の分だけ**——`peer-*` を一括で消すと同じマシンの別卓を巻き込む。
|
|
35
|
+
rm -f "$proj/.team/seats/$name.json"
|
|
27
36
|
tmux -S "$sock" new-session -d -s "$sess" -x 200 -y 50 -c "$proj"
|
|
28
37
|
|
|
29
38
|
# 素性は席の env にも入れる。client が**登録のたびに**載せるので、member の状態が失われても戻る
|
|
@@ -32,6 +41,7 @@ env_line="$env_line PEERTABLE_VENDOR=$vendor PEERTABLE_MODEL=$model"
|
|
|
32
41
|
[ -n "$effort" ] && env_line="$env_line PEERTABLE_EFFORT=$effort"
|
|
33
42
|
if [ "$mode" = "lattice" ]; then
|
|
34
43
|
env_line="$env_line PEERTABLE_PLAN=$plan LATTICE_TODO_ACTOR_HOST=${LATTICE_TODO_ACTOR_HOST:-mac} LATTICE_TODO_ACTOR_SESSION=$name LATTICE_TODO_ACTOR_AGENT=$name"
|
|
44
|
+
[ -n "$lattice_cli" ] && env_line="$env_line LATTICE_CLI=$lattice_cli"
|
|
35
45
|
fi
|
|
36
46
|
tmux -S "$sock" send-keys -t "$sess" "$env_line" Enter
|
|
37
47
|
sleep 1
|
|
@@ -48,6 +58,9 @@ case "$vendor" in
|
|
|
48
58
|
# (caveat `codex-cli-v0-130-0-mcp-servers-x-env-block-is-closed-mode-parent-env-not-inherited`)。
|
|
49
59
|
envtbl="PATH=\\\"$PATH\\\",PEERTABLE_URL=\\\"$url\\\",PEERTABLE_ROOM=\\\"$room\\\",PEERTABLE_MEMBER=\\\"$name\\\",PEERTABLE_POST_TOKEN=\\\"$PEERTABLE_POST_TOKEN\\\""
|
|
50
60
|
cmd="codex --model $model -C $proj --dangerously-bypass-approvals-and-sandbox"
|
|
61
|
+
# Codexのeffortは環境変数だけでは適用されない。member metadataへ表示する値と、
|
|
62
|
+
# 実際の推論設定を同じ引数から渡して食い違わせない。
|
|
63
|
+
[ -n "$effort" ] && cmd="$cmd -c 'model_reasoning_effort=\"$effort\"'"
|
|
51
64
|
cmd="$cmd -c 'mcp_servers.room.command=\"peertable-client\"'"
|
|
52
65
|
cmd="$cmd -c \"mcp_servers.room.env={$envtbl}\""
|
|
53
66
|
;;
|
|
@@ -94,6 +107,73 @@ fi
|
|
|
94
107
|
|
|
95
108
|
echo "seated: ${sess}(${vendor} / ${model}${effort:+ / $effort} / room=${room} / mode=${mode})"
|
|
96
109
|
|
|
110
|
+
# 席の素性を `.team/seats/<name>.json` へ置く。**席が自分の pid を知るための唯一の経路**である
|
|
111
|
+
# (Lattice の `run intake attach` は expected identity を要求し、pid を推定しない)。
|
|
112
|
+
# 着席の**後**に取る——起動途中の process を掴むと、ダイアログ通過で子が入れ替わりうる。
|
|
113
|
+
#
|
|
114
|
+
# 持たせるのは6欄だけで、**`lattice.pull_worker_attach_input.v1` の exact 集合から `schema` を
|
|
115
|
+
# 除いたもの**と一致する。席は読んで `schema` を被せるだけで attach input になる(変換不要)。
|
|
116
|
+
# **raw argv を持たせない**——Codex 起動の argv には `PEERTABLE_POST_TOKEN` が載るので、
|
|
117
|
+
# 保存すれば秘密の複製になる(2026-08-09 実測)。digest だけを持つ。
|
|
118
|
+
# この file が主張するのは「この pid はこの席だった」という**識別**であって、生死ではない。
|
|
119
|
+
# 生きているかは attach する側(Lattice)が lstart+argv の再観測で確かめる。
|
|
120
|
+
seat_pid=""
|
|
121
|
+
pane_pid=$(tmux -S "$sock" list-panes -t "$sess" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
|
|
122
|
+
if [ -n "$pane_pid" ]; then
|
|
123
|
+
# pane の子で pid===pgid のものが席本体。pane_pid(shell)を渡すと Lattice の直接 OS 観測が
|
|
124
|
+
# 「worker process group を無関係 process と共有している」で正しく落ちる。
|
|
125
|
+
# **1件でなければ推測で選ばない**(run-bridge.mjs の seatWorkerPid と同じ規律)。
|
|
126
|
+
leaders=$(ps -Ao pid=,ppid=,pgid= | awk -v p="$pane_pid" '$2==p && $1==$3 {print $1}')
|
|
127
|
+
if [ "$(printf '%s\n' "$leaders" | grep -c .)" = "1" ]; then seat_pid=$(printf '%s' "$leaders" | tr -d ' \n'); fi
|
|
128
|
+
fi
|
|
129
|
+
if [ -z "$seat_pid" ]; then
|
|
130
|
+
# 記録が無ければ席は attach できず、装置の介入は協調 hold のままになる。**黙らない。**
|
|
131
|
+
echo "seat identity を記録できなかった: ${sess} の process group leader を1つに確定できない(席は着席済み)" >&2
|
|
132
|
+
else
|
|
133
|
+
mkdir -p "$proj/.team/seats"
|
|
134
|
+
# **`session` に tmux 名(`peer-<name>`)を入れてはいけない。** Lattice の attach は
|
|
135
|
+
# `input.session === actor.session` を要求し(`runtime-pull-intake.mjs:674`)、actor session は
|
|
136
|
+
# 上の env_line が入れる `LATTICE_TODO_ACTOR_SESSION=$name` である。tmux 識別子を混ぜると
|
|
137
|
+
# attach が必ず `WORKER_ACTOR_MISMATCH` で拒否される(mio の監査で発覚・room [937])。
|
|
138
|
+
if ! python3 - "$proj/.team/seats/$name.json" "$name" "$name" "$seat_pid" <<'PY'
|
|
139
|
+
import hashlib, json, os, subprocess, sys, tempfile
|
|
140
|
+
out, name, session, pid = sys.argv[1:5]
|
|
141
|
+
pid = int(pid)
|
|
142
|
+
started = subprocess.run(['/bin/ps', '-o', 'lstart=', '-p', str(pid)],
|
|
143
|
+
capture_output=True, text=True, check=True).stdout.strip()
|
|
144
|
+
argv = subprocess.run(['/bin/ps', '-o', 'args=', '-p', str(pid)],
|
|
145
|
+
capture_output=True, text=True, check=True).stdout.strip()
|
|
146
|
+
if not started or not argv:
|
|
147
|
+
sys.exit('pid の lstart/args を観測できない')
|
|
148
|
+
record = {
|
|
149
|
+
'argv_digest': hashlib.sha256(argv.encode()).hexdigest(),
|
|
150
|
+
'name': name,
|
|
151
|
+
'pid': pid,
|
|
152
|
+
'recorded_at': subprocess.run(['date', '-u', '+%Y-%m-%dT%H:%M:%S.000Z'],
|
|
153
|
+
capture_output=True, text=True, check=True).stdout.strip(),
|
|
154
|
+
'session': session,
|
|
155
|
+
'started_identity': started,
|
|
156
|
+
}
|
|
157
|
+
# canonical JSON(key 昇順・空白なし)+ 0600 + 一時file→fsync→rename で原子的に置く。
|
|
158
|
+
# 着席直後に席が読むので、部分読取が起きない形にする。
|
|
159
|
+
body = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + '\n'
|
|
160
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(out), prefix='.seat-', suffix='.tmp')
|
|
161
|
+
try:
|
|
162
|
+
with os.fdopen(fd, 'w') as handle:
|
|
163
|
+
handle.write(body)
|
|
164
|
+
handle.flush()
|
|
165
|
+
os.fsync(handle.fileno())
|
|
166
|
+
os.chmod(tmp, 0o600)
|
|
167
|
+
os.replace(tmp, out)
|
|
168
|
+
except BaseException:
|
|
169
|
+
os.unlink(tmp)
|
|
170
|
+
raise
|
|
171
|
+
PY
|
|
172
|
+
then
|
|
173
|
+
echo "seat identity を記録できなかった: ${sess}(席は着席済み・attach は協調 hold のままになる)" >&2
|
|
174
|
+
fi
|
|
175
|
+
fi
|
|
176
|
+
|
|
97
177
|
# 席の素性(vendor / model / effort)を room へ渡す。参加者一覧のホバー表示に使う。
|
|
98
178
|
# 席自身の client も起動時に `{name}` だけで登録するので、server 側は
|
|
99
179
|
# **欄が無い登録で既存の素性を消さない**(upsert)ことが前提である。
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# 親(ベル等)が room
|
|
3
|
-
# usage: parent-join.sh <project_dir> [name] [
|
|
4
|
-
# name 既定は bell。
|
|
5
|
-
# (複数行・記号を安全に運ぶためファイル渡しにしている。引数のインライン渡しはしない)。
|
|
2
|
+
# 親(ベル等)が room へ着卓する。
|
|
3
|
+
# usage: parent-join.sh <project_dir> [name] [model] [effort]
|
|
4
|
+
# name 既定は bell。broadcast廃止に伴いkickoff投稿は行わない。
|
|
6
5
|
# model / effort は任意。親はオーナーの対話セッション(決定40)なので、席と違って
|
|
7
6
|
# 起動時に確定した値を script が知らない——**渡された時だけ**参加者一覧の素性として登録する。
|
|
8
7
|
# 渡さなければ欄ごと出ない(「不明」ではなく「素性を名乗っていない」)。
|
|
9
8
|
# 親は MCP を後付けできないので room へは HTTP API 直で入る(決定40 の operating notes)。
|
|
10
9
|
set -e
|
|
11
|
-
proj="$1"; name="${2:-bell}";
|
|
10
|
+
proj="$1"; name="${2:-bell}"; model="$3"; effort="$4"
|
|
12
11
|
state="$proj/.team/setup-state.json"
|
|
13
12
|
room=$(python3 -c "import json;print(json.load(open('$state'))['room'])")
|
|
14
13
|
url=$(python3 -c "import json;print(json.load(open('$state'))['server_url'])")
|
|
@@ -35,18 +34,4 @@ curl -sf -X POST "$url/api/$room/members" \
|
|
|
35
34
|
-d "$member" > /dev/null
|
|
36
35
|
echo "joined: ${name}(room=${room})"
|
|
37
36
|
|
|
38
|
-
if [ -n "$kickoff" ]; then
|
|
39
|
-
# JSON の組み立てはヒアドキュメントで行う。`-c` にインラインで書くと本文の `{...}`
|
|
40
|
-
# をシェルのブレース展開が刻んで壊す(2026-08-08 実測)
|
|
41
|
-
body=$(python3 - "$name" "$kickoff" <<'PY'
|
|
42
|
-
import json, sys
|
|
43
|
-
print(json.dumps({'from': sys.argv[1], 'to': 'all', 'body': open(sys.argv[2]).read().strip()}))
|
|
44
|
-
PY
|
|
45
|
-
)
|
|
46
|
-
curl -sf -X POST "$url/api/$room/messages" \
|
|
47
|
-
-H "X-Peertable-Token: $PEERTABLE_POST_TOKEN" -H 'content-type: application/json' \
|
|
48
|
-
-d "$body" > /dev/null
|
|
49
|
-
echo "kickoff posted: $kickoff"
|
|
50
|
-
fi
|
|
51
|
-
|
|
52
37
|
curl -sf "$url/api/$room/members" | python3 -c "import json,sys;print('members:', ', '.join(m['name'] for m in json.load(sys.stdin)['members']))"
|
|
@@ -0,0 +1,259 @@
|
|
|
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, unlinkSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { promisify } from 'node:util'
|
|
27
|
+
|
|
28
|
+
const run = promisify(execFile)
|
|
29
|
+
const [proj, ...rest] = process.argv.slice(2)
|
|
30
|
+
if (!proj) {
|
|
31
|
+
console.error('usage: run-bridge.mjs <project_dir> [--lattice <path>] | <project_dir> --stop')
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const record = join(proj, '.team', 'run-bridge.json')
|
|
36
|
+
const alive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
37
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
38
|
+
const log = line => console.log(`[${new Date().toISOString()}] ${line}`)
|
|
39
|
+
|
|
40
|
+
// pid だけを頼りに signal を送らない(ADR 0157)。**pid は再利用される**ので、記録した
|
|
41
|
+
// 起動時刻(ps の lstart)と command line を照合し、通った相手にだけ送る。照合が合わない記録は
|
|
42
|
+
// 「自分の常駐ではない誰か」なので、掃除はしても**殺さない**。
|
|
43
|
+
async function processFacts(pid) {
|
|
44
|
+
let stdout
|
|
45
|
+
try { ({ stdout } = await run('/bin/ps', ['-o', 'lstart=,command=', '-p', String(pid)])) }
|
|
46
|
+
catch { return null }
|
|
47
|
+
const line = stdout.split('\n')[0]?.trim() ?? ''
|
|
48
|
+
if (line.length === 0) return null
|
|
49
|
+
// lstart は固定幅の `Sun Aug 9 08:11:02 2026`(5 token)。残りが command line
|
|
50
|
+
const parts = line.split(/\s+/)
|
|
51
|
+
return { startIdentity: parts.slice(0, 5).join(' '), command: parts.slice(5).join(' ') }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function stopRecorded({ strict = false } = {}) {
|
|
55
|
+
if (!existsSync(record)) return
|
|
56
|
+
const stored = JSON.parse(readFileSync(record, 'utf8'))
|
|
57
|
+
const { pid } = stored
|
|
58
|
+
const facts = await processFacts(pid)
|
|
59
|
+
if (facts === null || !alive(pid)) {
|
|
60
|
+
unlinkSync(record); log(`死んだ記録を掃除した(pid ${pid})`); return
|
|
61
|
+
}
|
|
62
|
+
const sameProcess = stored.start_identity === undefined
|
|
63
|
+
? false // 旧形式の記録は再認証できない=殺さない
|
|
64
|
+
: facts.startIdentity === stored.start_identity
|
|
65
|
+
&& facts.command.includes('run-bridge.mjs')
|
|
66
|
+
&& facts.command.includes(proj)
|
|
67
|
+
if (!sameProcess) {
|
|
68
|
+
unlinkSync(record)
|
|
69
|
+
const detail = `pid ${pid} は記録した常駐ではない(観測: ${facts.startIdentity} / ${facts.command.slice(0, 120)})`
|
|
70
|
+
if (strict) {
|
|
71
|
+
console.error(`RUN_BRIDGE_RECORD_STALE: ${detail}。**signal は送っていない**——`
|
|
72
|
+
+ '本物の常駐が別 pid で生きている可能性があるので、`ps` で確認して手で止めること')
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
log(`RUN_BRIDGE_RECORD_STALE: ${detail}。signal を送らずに記録だけ掃除した`)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
process.kill(pid, 'SIGTERM')
|
|
79
|
+
for (let i = 0; i < 25 && alive(pid); i++) await sleep(200)
|
|
80
|
+
if (alive(pid)) {
|
|
81
|
+
process.kill(pid, 'SIGKILL')
|
|
82
|
+
for (let i = 0; i < 15 && alive(pid); i++) await sleep(200)
|
|
83
|
+
}
|
|
84
|
+
if (alive(pid)) {
|
|
85
|
+
console.error(`RUN_BRIDGE_STOP_FAILED: pid ${pid} が SIGKILL でも止まらない`)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
if (existsSync(record)) unlinkSync(record)
|
|
89
|
+
log(`前のブリッジを停止した(pid ${pid})`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// **引数の検査は前のブリッジを止めるより先にやる。** 逆順にすると、旧版の command line
|
|
93
|
+
// (spool dir と席名つき)で叩いた人が「動いているブリッジを殺してから起動に失敗する」——
|
|
94
|
+
// 正典が旧形式を載せていた期間があるので、これは実際に起きる(自分で踏んで気づいた)。
|
|
95
|
+
// `--stop` はここを通さない。止めるだけの呼び出しに `--lattice` は要らない。
|
|
96
|
+
let latticeCli = 'lattice'
|
|
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 へ返すだけである')
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await stopRecorded({ strict: rest[0] === '--stop' })
|
|
120
|
+
if (rest[0] === '--stop') process.exit(0)
|
|
121
|
+
|
|
122
|
+
const state = JSON.parse(readFileSync(join(proj, '.team', 'setup-state.json'), 'utf8'))
|
|
123
|
+
const { room, server_url: url } = state
|
|
124
|
+
const token = process.env.PEERTABLE_POST_TOKEN ?? ''
|
|
125
|
+
if (token.length === 0) {
|
|
126
|
+
// 投稿できないブリッジは何も返せない。起きてから黙って何もしない常駐を作らない
|
|
127
|
+
console.error('RUN_BRIDGE_TOKEN_MISSING: PEERTABLE_POST_TOKEN が無い(export し忘れていないか)')
|
|
128
|
+
process.exit(1)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 記録には pid だけでなく**起動時刻と command line**を入れる。停止側はこれで再認証する
|
|
132
|
+
const selfFacts = await processFacts(process.pid)
|
|
133
|
+
if (selfFacts === null) {
|
|
134
|
+
console.error('RUN_BRIDGE_SELF_UNOBSERVABLE: 自分の process を ps で観測できない')
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
writeFileSync(record, JSON.stringify({
|
|
138
|
+
pid: process.pid, start_identity: selfFacts.startIdentity, command: selfFacts.command,
|
|
139
|
+
room, server_url: url, started_at: new Date().toISOString(),
|
|
140
|
+
}) + '\n')
|
|
141
|
+
const cleanup = () => { if (existsSync(record)) unlinkSync(record); process.exit(0) }
|
|
142
|
+
process.on('SIGTERM', cleanup)
|
|
143
|
+
process.on('SIGINT', cleanup)
|
|
144
|
+
|
|
145
|
+
async function post(to, body) {
|
|
146
|
+
const res = await fetch(`${url}/api/${encodeURIComponent(room)}/messages`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'content-type': 'application/json', 'x-peertable-token': token },
|
|
149
|
+
body: JSON.stringify({ from: 'run-bridge', to, body }),
|
|
150
|
+
})
|
|
151
|
+
if (!res.ok) throw new Error(`room post ${res.status}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
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)
|
|
159
|
+
}
|
|
160
|
+
|
|
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
|
+
}
|
|
177
|
+
|
|
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}`
|
|
184
|
+
}
|
|
185
|
+
|
|
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')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function pollRuns() {
|
|
202
|
+
let refs
|
|
203
|
+
try { refs = await pullRunRefs() }
|
|
204
|
+
catch (error) {
|
|
205
|
+
// 観測できないことを黙らない。**沈黙を「異常なし」の証拠にしない**
|
|
206
|
+
const detail = String(error?.stderr ?? error?.message ?? error).split('\n')[0].slice(0, 200)
|
|
207
|
+
log(`run list 失敗: ${detail}`)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
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}`) }
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
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}`)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
log(`介入: ${ref} ${intake.task_id} → ${seat}(${intake.intervention.reason ?? ''})`)
|
|
235
|
+
await post(seat, interventionText(ref, intake))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const summary = summarize(observation)
|
|
239
|
+
if (runSummaries.get(ref) !== summary) {
|
|
240
|
+
runSummaries.set(ref, summary)
|
|
241
|
+
log(`run 進行: ${ref} ${summary}`)
|
|
242
|
+
}
|
|
243
|
+
// closed は終端。最後の状態をlocal logへ記録してから外す
|
|
244
|
+
if (observation.closed === true) {
|
|
245
|
+
closedRuns.add(ref)
|
|
246
|
+
log(`run 終端を観測したので poll を止める: ${ref}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// 変化が無い時も1分に1回は件数を出す(沈黙を「異常なし」の証拠にしない)
|
|
250
|
+
pollTicks += 1
|
|
251
|
+
if (pollTicks % 6 === 1) log(`pull run ${refs.length} 件を観測中`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
log(`bridge start: room=${room} project=${proj} pid=${process.pid}(配車はしない・観測と中継だけ)`)
|
|
255
|
+
for (;;) {
|
|
256
|
+
try { await pollRuns() }
|
|
257
|
+
catch (error) { log(`run 観測に失敗(続行する): ${error.message}`) }
|
|
258
|
+
await sleep(10_000)
|
|
259
|
+
}
|
|
@@ -20,6 +20,8 @@ import { execFileSync } from 'node:child_process'
|
|
|
20
20
|
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
21
21
|
import { join } from 'node:path'
|
|
22
22
|
|
|
23
|
+
import { parsePaneTokenHint, supportsMemberObservation } from './seat-usage.mjs'
|
|
24
|
+
|
|
23
25
|
const args = process.argv.slice(2)
|
|
24
26
|
const proj = args[0]
|
|
25
27
|
if (!proj) { console.error('usage: seat-status-bridge.mjs <project_dir> [--interval <sec>] [--once] | --stop'); process.exit(1) }
|
|
@@ -77,21 +79,35 @@ async function seats() {
|
|
|
77
79
|
return members.map(m => m.name)
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
function
|
|
82
|
+
function readSeat(name, previous, observedAt) {
|
|
81
83
|
const target = `peer-${name}`
|
|
82
84
|
const dead = tmux('list-panes', '-t', target, '-F', '#{pane_dead}')
|
|
83
|
-
if (dead === null) return 'dead'
|
|
84
|
-
if (dead.trim().split('\n')[0] === '1')
|
|
85
|
+
if (dead === null) return { status: 'dead', busySince: null, paneTokenHint: null }
|
|
86
|
+
if (dead.trim().split('\n')[0] === '1') {
|
|
87
|
+
return { status: 'dead', busySince: null, paneTokenHint: null }
|
|
88
|
+
}
|
|
85
89
|
const pane = tmux('capture-pane', '-t', target, '-p')
|
|
86
|
-
if (pane === null) return 'dead'
|
|
87
|
-
|
|
90
|
+
if (pane === null) return { status: 'dead', busySince: null, paneTokenHint: null }
|
|
91
|
+
const tail = pane.split('\n').slice(-14).join('\n')
|
|
92
|
+
const status = tail.includes('esc to interrupt') ? 'busy' : 'idle'
|
|
93
|
+
const busySince = status === 'busy'
|
|
94
|
+
? (previous?.status === 'busy' && previous.busySince ? previous.busySince : observedAt)
|
|
95
|
+
: null
|
|
96
|
+
return { status, busySince, paneTokenHint: parsePaneTokenHint(tail) }
|
|
88
97
|
}
|
|
89
98
|
|
|
90
|
-
async function send(name,
|
|
99
|
+
async function send(name, observation, observedAt) {
|
|
91
100
|
const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`, {
|
|
92
101
|
method: 'POST',
|
|
93
102
|
headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Peertable-Token': token } : {}) },
|
|
94
|
-
body: JSON.stringify({
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
name,
|
|
105
|
+
status: observation.status,
|
|
106
|
+
status_at: observedAt,
|
|
107
|
+
busy_since: observation.busySince,
|
|
108
|
+
pane_token_hint: observation.paneTokenHint,
|
|
109
|
+
usage_source: 'pane_status',
|
|
110
|
+
}),
|
|
95
111
|
})
|
|
96
112
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
97
113
|
}
|
|
@@ -100,12 +116,12 @@ async function send(name, status) {
|
|
|
100
116
|
// 読み返して実際に載ったかを見る。載らない版なら、そう言って**黙って成功したふりをしない**
|
|
101
117
|
async function serverKeepsStatus() {
|
|
102
118
|
const res = await fetch(`${url}/api/${encodeURIComponent(room)}/members`)
|
|
103
|
-
|
|
104
|
-
return members.some(m => 'status' in m)
|
|
119
|
+
return supportsMemberObservation(await res.json())
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
const last = new Map() // name -> { status, at }
|
|
108
123
|
let supported = null // server が status を保持する版か(未判定は null)
|
|
124
|
+
const tokenBucket = value => value === null ? null : Math.floor(value / 1_000)
|
|
109
125
|
|
|
110
126
|
async function tick() {
|
|
111
127
|
let names
|
|
@@ -121,18 +137,22 @@ async function tick() {
|
|
|
121
137
|
}
|
|
122
138
|
if (!supported) { console.error(`seat-status-bridge: ${names.length} 席を見たが、server が未対応なので送っていない`); return }
|
|
123
139
|
const now = Date.now()
|
|
140
|
+
const observedAt = new Date(now).toISOString()
|
|
124
141
|
let sent = 0
|
|
125
142
|
for (const name of names) {
|
|
126
|
-
const status = readStatus(name)
|
|
127
143
|
const prev = last.get(name)
|
|
128
|
-
const
|
|
144
|
+
const observation = readSeat(name, prev, observedAt)
|
|
145
|
+
const changed = !prev || prev.status !== observation.status
|
|
146
|
+
|| prev.busySince !== observation.busySince
|
|
147
|
+
// token表示は実行中に細かく増える。1k未満の差で8秒ごとにPOSTせず、表示精度に合う粒度で送る。
|
|
148
|
+
|| tokenBucket(prev.paneTokenHint) !== tokenBucket(observation.paneTokenHint)
|
|
129
149
|
const stale = prev && now - prev.at >= HEARTBEAT_MS
|
|
130
150
|
if (!changed && !stale) continue
|
|
131
151
|
try {
|
|
132
|
-
await send(name,
|
|
133
|
-
last.set(name, {
|
|
152
|
+
await send(name, observation, observedAt)
|
|
153
|
+
last.set(name, { ...observation, at: now })
|
|
134
154
|
sent++
|
|
135
|
-
if (changed) console.error(`seat-status-bridge: ${name} → ${status}${prev ? `(${prev.status} から)` : ''}`)
|
|
155
|
+
if (changed) console.error(`seat-status-bridge: ${name} → ${observation.status}${prev ? `(${prev.status} から)` : ''}`)
|
|
136
156
|
} catch (e) {
|
|
137
157
|
console.error(`seat-status-bridge: ${name} の送信に失敗: ${e.message}`)
|
|
138
158
|
}
|