@tyhld/conductor 0.12.30 → 0.12.32

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.30",
3
+ "version": "0.12.32",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  python3 skill_sync_notify.py <直近の試みの控えのパス>
33
33
  ★控えの1行目が status=failed のときだけカードを出す。ok のときは何もしない。
34
34
  """
35
+ import json
35
36
  import os
36
37
  import sys
37
38
 
@@ -102,21 +103,54 @@ def parse_last(path):
102
103
  return out
103
104
 
104
105
 
106
+ #: 入れ直しの1行(★取得先を明示する。ここを省くと社内の取得先を見に行って失敗する)。
107
+ REINSTALL = ('npm install -g @tyhld/conductor '
108
+ '--@tyhld:registry=https://registry.npmjs.org')
109
+ #: 配り直しの1行(★入っている配布物の中の台本を打つ。作業ツリーの台本ではない)。
110
+ RESYNC = 'APPLY=1 bash "$(npm root -g)/@tyhld/conductor/scripts/update-guard.sh"'
111
+
112
+
113
+ def recovery_steps(reason):
114
+ """うまくいかなかった理由に【合った】手順を返す(便 675e2d05)。
115
+
116
+ 【なぜ理由ごとに分けるか(実地 2026-09-20・便 8692c586 の重大発見1)】
117
+ 以前は理由によらず「update-guard を打ってください」の固定文だった。version-drift
118
+ (入っている版と公開の版が違う)のときにこれを打っても、配り元の確認でまた弾かれる=
119
+ 同じ失敗を繰り返すだけで直らない。先に入れ直しが要る。
120
+ """
121
+ if reason in ('version-drift', 'not-installed', 'incomplete'):
122
+ return [f'再開のしかた: この PC で次の順に打ってください。① {REINSTALL}',
123
+ f' ② {RESYNC}']
124
+ if reason == 'unverifiable':
125
+ return ['再開のしかた: 公開 npm に問い合わせられませんでした。'
126
+ 'この PC の通信(回線・取得先の設定)を確かめてから、次を打ってください。',
127
+ f' {RESYNC}']
128
+ return [f'再開のしかた: この PC で次の1行を打ってください。 {RESYNC}']
129
+
130
+
105
131
  def build_payload(rec, env=None):
106
- """カードの中身。★何が起きていて、何をすれば直るかを日本語で書く。"""
132
+ """カードの中身。★何が起きていて、何をすれば直るかを日本語で書く。
133
+
134
+ ★本文は【その回の控え(rec)の実際の値】だけから組み立てる。固定の版番号や、
135
+ もう済んだ操作の案内を焼き込まない(便 675e2d05・えふさん確定 2026-09-20)。
136
+ """
107
137
  env = os.environ if env is None else env
108
138
  machine = env.get('CONDUCTOR_MACHINE') or os.uname().nodename
109
139
  reason = rec.get('reason', '不明')
110
140
  kind = notice_kind(rec)
111
- body = '\n'.join([
141
+ lines = [
112
142
  f'いま止まっているもの: この PC({machine})で、{kind["stopped"]}',
113
- '再開のしかた: この PC で次の1行を打ってください。'
114
- ' cd ~/projects/conductor そのあと APPLY=1 bash scripts/update-guard.sh',
143
+ *recovery_steps(reason),
115
144
  '★このカードは管制からは押せません(この PC での操作が要ります)。',
116
145
  f'PC: {machine}/うまくいかなかった理由: {reason}',
117
146
  f'最後に試した時刻: {rec.get("at", "不明")}',
118
- f'配ろうとした版: {rec.get("version", "不明")}',
119
- ])
147
+ f'入っている版: {rec.get("version", "不明")}',
148
+ ]
149
+ # ★公開の版は、控えに在るときだけ載せる。無い種類の知らせに「公開の版: 不明」と
150
+ # 書くと、読む人には【公開の版が読めなかった】ように見える(便 675e2d05)。
151
+ if rec.get('published'):
152
+ lines.append(f'公開の版: {rec["published"]}')
153
+ body = '\n'.join(lines)
120
154
  return {
121
155
  'site': env.get('CONDUCTOR_SITE') or machine,
122
156
  'machine': machine,
@@ -131,19 +165,78 @@ def build_payload(rec, env=None):
131
165
  }
132
166
 
133
167
 
134
- def run(last_path, cfg=None, datalink=hook_datalink):
135
- """返り値: 出したカードのID / None(出さなかった)。"""
168
+ #: 出した札の番号を覚えておく場所(鍵→カードID)。★秘密は入らない(番号だけ)。
169
+ CARD_STATE = os.path.join(os.path.expanduser('~'), '.tyhld', 'hooks', 'notice-cards.json')
170
+
171
+
172
+ def _load_cards(path=None):
173
+ """覚えている札の番号。読めなければ空(覚えていない=今までどおりに振る舞う)。"""
174
+ try:
175
+ with open(path or CARD_STATE, encoding='utf-8') as f:
176
+ got = json.load(f)
177
+ return got if isinstance(got, dict) else {}
178
+ except Exception: # noqa: BLE001
179
+ return {}
180
+
181
+
182
+ def _save_cards(cards, path=None):
183
+ """覚え書きを書く。★書けなくても止まらない(知らせは本筋ではない)。"""
184
+ target = path or CARD_STATE
185
+ try:
186
+ os.makedirs(os.path.dirname(target), exist_ok=True)
187
+ tmp = target + '.tmp'
188
+ with open(tmp, 'w', encoding='utf-8') as f:
189
+ json.dump(cards, f, ensure_ascii=False)
190
+ os.replace(tmp, target)
191
+ except Exception: # noqa: BLE001
192
+ pass
193
+
194
+
195
+ def _close(cfg, datalink, cards, key, path=None):
196
+ """覚えている札を閉じる(best effort)。閉じたら覚え書きから外す。"""
197
+ card_id = cards.get(key)
198
+ if not card_id:
199
+ return False
200
+ try:
201
+ datalink.abandon(cfg, card_id)
202
+ except Exception: # noqa: BLE001
203
+ pass
204
+ cards.pop(key, None)
205
+ _save_cards(cards, path)
206
+ return True
207
+
208
+
209
+ def run(last_path, cfg=None, datalink=hook_datalink, state_path=None):
210
+ """返り値: 出したカードのID / None(出さなかった・閉じただけ)。
211
+
212
+ ★失敗の回 … 前に出した札を【閉じてから】出し直す。管制は同じ鍵の再登録を寿命の延長
213
+ として扱い、本文は最初のままになる(実地 2026-09-20・札に 4日前の版が出ていた)。
214
+ 閉じてから出し直せば、本文は必ずその回の値になる。
215
+ ★成功の回 … 出していた札を閉じる。直ったのに札が最大60分出たままにならない。
216
+ """
136
217
  rec = parse_last(last_path)
137
- if rec.get('status') != 'failed':
218
+ status = rec.get('status')
219
+ if status not in ('failed', 'ok'):
138
220
  return None
139
221
  cfg = datalink.load_config() if cfg is None else cfg
140
222
  if cfg is None:
141
223
  # 接続情報が無い機体。★控えには残っているので、黙って消えるわけではない。
142
224
  return None
225
+ key = notice_kind(rec)['key']
226
+ cards = _load_cards(state_path)
227
+
228
+ if status == 'ok':
229
+ _close(cfg, datalink, cards, key, state_path)
230
+ return None
231
+
232
+ # 失敗。★古い本文の札が残っていたら先に閉じる(上書きできない作りへの対処)。
233
+ _close(cfg, datalink, cards, key, state_path)
143
234
  reg = datalink.register(cfg, build_payload(rec))
144
235
  if not reg or not reg.get('id'):
145
236
  return None
146
- # ★待たない。押されるまで管制に出たままにする=古い鉄則のままであることが見え続ける。
237
+ cards[key] = reg['id']
238
+ _save_cards(cards, state_path)
239
+ # ★待たない。押されるまで管制に出たままにする=直っていないことが見え続ける。
147
240
  return reg['id']
148
241
 
149
242
 
@@ -1087,7 +1087,9 @@ check('同期モードは掃除・タイマー・pnpm 点検を飛ばす',
1087
1087
  check('職人の鉄則は同期モードでも配る',
1088
1088
  _ug.index('①-d 職人の鉄則スキル') < _ug.index('①-b・①-c・①-d2 は省略(同期モード)'), True)
1089
1089
  check('配り損ねたら成功と偽らず管制へ知らせる',
1090
- 'craftsman_notify_failure' in _ug and 'skill_sync_notify.py' in _ug, True)
1090
+ 'craftsman_notify' in _ug and 'skill_sync_notify.py' in _ug, True)
1091
+ # ★直った回は札を閉じる(便 675e2d05)。直っているのに札が最大60分出たままだった。
1092
+ check('直った回は管制の札を閉じる', 'craftsman_notify_recovered' in _ug, True)
1091
1093
  # ★上書きの前に日時つきの控えを取る(えふさん確定 2026-09-16・便 bf030fee)。
1092
1094
  # 中身の検査は test/craftsman-skill-sync.test.ts。ここでは決まりが消えていないことだけ見る。
1093
1095
  check('上書きの前に控えを取る', 'craftsman_backup()' in _ug, True)
@@ -6,7 +6,7 @@
6
6
 
7
7
  【何を守っているか(便 29612ff1・えふさん確定 2026-09-16)】
8
8
  「★更新失敗時に古いルールのまま黙って動かないこと。適用version/時刻を確認可能にする」
9
- (a) 成功の控えでは【1件も知らせない】(毎時のカードで管制を埋めない)
9
+ (a) 成功の控えでは【新しい札を1枚も出さない】(毎時のカードで管制を埋めない)
10
10
  (b) 失敗の控えでは【必ず知らせる】=黙って進まない
11
11
  (c) カードの中身に「いま止まっているもの」「再開のしかた」が入っている
12
12
  =管制が知らせとして描ける形(ai-kana の hook-notice-text.ts が探す見出し語)
@@ -15,7 +15,16 @@
15
15
  (e) 機体ごとに鍵が固定=毎時の失敗が同じ1枚に集約される(カードが増殖しない)
16
16
  (f) 知らせに失敗しても例外を外へ出さない(★同期そのものは止めない)
17
17
  (g) 控えが読めない・空・壊れていても落ちない
18
+
19
+ 【★便 675e2d05 で足した3つ(実地 2026-09-20・えふさん確定)】
20
+ (h) 失敗の回は【前の札を閉じてから】出し直す=本文が必ずその回の値になる。
21
+ 管制は同じ鍵の再登録を寿命の延長として扱い、本文は最初のまま残る(upsertHookRequest)。
22
+ 実地では、直しても札に4日前の版が出たままだった。
23
+ (i) 成功の回は【出していた札を閉じる】=直ったのに札が最大60分出たままにならない。
24
+ (j) 再開の手順が【うまくいかなかった理由に合っている】=同じ失敗を繰り返させない。
25
+ 版のずれに「配り直してください」とだけ言っても、配り元の確認でまた弾かれる。
18
26
  """
27
+ import json
19
28
  import os
20
29
  import sys
21
30
  import tempfile
@@ -51,22 +60,49 @@ class FakeLink:
51
60
  #: 「既定のまま」と「わざと None を返させる」を区別するための印。
52
61
  DEFAULT = object()
53
62
 
54
- def __init__(self, cfg=CFG, reg=DEFAULT):
63
+ def __init__(self, cfg=CFG, reg=DEFAULT, ids=None, abandon_raises=False):
55
64
  self.cfg = cfg
56
65
  self.reg = {'id': 'req-1'} if reg is FakeLink.DEFAULT else reg
57
- self.sent = []
66
+ self.ids = list(ids) if ids else None # 連番で返したいとき
67
+ self.abandon_raises = abandon_raises
68
+ self.sent = [] # 送ったカードの中身
69
+ self.closed = [] # 閉じに行ったカードID
70
+ self.log = [] # ('close'|'register', …) の並び=★順序を見るため
58
71
 
59
72
  def load_config(self, env=None):
60
73
  return self.cfg
61
74
 
62
75
  def register(self, cfg, payload):
63
76
  self.sent.append(payload)
77
+ self.log.append(('register', payload['requestKey']))
78
+ if self.ids is not None:
79
+ return {'id': self.ids.pop(0)} if self.ids else None
64
80
  return self.reg
65
81
 
82
+ def abandon(self, cfg, request_id):
83
+ self.closed.append(request_id)
84
+ self.log.append(('close', request_id))
85
+ if self.abandon_raises:
86
+ raise RuntimeError('通信断')
87
+ return {'ok': True}
88
+
66
89
  # ★本物と同じ実装を借りる(sessionId が空にならないことを一緒に確かめるため)。
67
90
  stable_session_id = staticmethod(sn.hook_datalink.stable_session_id)
68
91
 
69
92
 
93
+ def real_state_snapshot():
94
+ """【本物】の覚え書きの中身(無ければ None)。★試験が実機を汚していないかを見るため。"""
95
+ try:
96
+ with open(sn.CARD_STATE, encoding='utf-8') as f:
97
+ return f.read()
98
+ except Exception: # noqa: BLE001
99
+ return None
100
+
101
+
102
+ #: 試験を始める前の姿。終わりに同じであることを確かめる。
103
+ REAL_STATE_BEFORE = real_state_snapshot()
104
+
105
+
70
106
  def write(tmp, name, text):
71
107
  p = os.path.join(tmp, name)
72
108
  with open(p, 'w', encoding='utf-8') as f:
@@ -74,30 +110,48 @@ def write(tmp, name, text):
74
110
  return p
75
111
 
76
112
 
113
+ def state(tmp, name='cards.json'):
114
+ """★札の覚え書きは必ずこの回だけの場所へ。
115
+
116
+ 既定は $HOME/.tyhld/hooks/notice-cards.json =【本物】の置き場なので、試験で
117
+ そこへ書くと、実機に居もしないカードの番号が残り、次の同期が存在しない札を
118
+ 閉じに行く。試験は本物の控えに1バイトも書かない。
119
+ """
120
+ return os.path.join(tmp, name)
121
+
122
+
77
123
  # ── (a)(b) 出す・出さない ────────────────────────────────────────────────────
78
124
  print('\n【(a)(b) 成功では出さない・失敗では必ず出す】')
79
125
  with tempfile.TemporaryDirectory() as tmp:
126
+ st = state(tmp)
80
127
  link = FakeLink()
81
- check('成功の控え: 知らせない', sn.run(write(tmp, 'ok.last', OK_LINE + '\n'), datalink=link), None)
128
+ check('成功の控え: 知らせない',
129
+ sn.run(write(tmp, 'ok.last', OK_LINE + '\n'), datalink=link, state_path=st), None)
82
130
  check('成功の控え: 1件も送っていない', len(link.sent), 0)
131
+ check('成功の控え: 覚えが無ければ閉じにも行かない', len(link.closed), 0)
132
+ check('成功の控え: 覚え書きを作らない', os.path.exists(st), False)
83
133
 
84
134
  link = FakeLink()
85
- got = sn.run(write(tmp, 'ng.last', NG_LINE + '\n'), datalink=link)
135
+ got = sn.run(write(tmp, 'ng.last', NG_LINE + '\n'), datalink=link, state_path=st)
86
136
  check('失敗の控え: 知らせた(カードIDが返る)', got, 'req-1')
87
137
  check('失敗の控え: 1件だけ送った', len(link.sent), 1)
88
138
 
89
139
  # ★控えが無い・空・壊れている=出さない(落ちない)=(g)
90
140
  link = FakeLink()
91
- check('控えが無い: 落ちずに何もしない', sn.run(os.path.join(tmp, 'none.last'), datalink=link), None)
92
- check('控えが空: 落ちずに何もしない', sn.run(write(tmp, 'e.last', ''), datalink=link), None)
93
- check('控えが壊れている: 落ちずに何もしない', sn.run(write(tmp, 'b.last', 'ゴミ\n'), datalink=link), None)
141
+ check('控えが無い: 落ちずに何もしない',
142
+ sn.run(os.path.join(tmp, 'none.last'), datalink=link, state_path=st), None)
143
+ check('控えが空: 落ちずに何もしない',
144
+ sn.run(write(tmp, 'e.last', ''), datalink=link, state_path=st), None)
145
+ check('控えが壊れている: 落ちずに何もしない',
146
+ sn.run(write(tmp, 'b.last', 'ゴミ\n'), datalink=link, state_path=st), None)
94
147
  check('壊れた控えでは1件も送らない', len(link.sent), 0)
148
+ check('壊れた控えでは札も閉じない(出したままにする)', len(link.closed), 0)
95
149
 
96
150
  # ── (c)(d)(e) カードの形 ────────────────────────────────────────────────────
97
151
  print('\n【(c)(d)(e) カードの形】')
98
152
  with tempfile.TemporaryDirectory() as tmp:
99
153
  link = FakeLink()
100
- sn.run(write(tmp, 'ng.last', NG_LINE + '\n'), datalink=link)
154
+ sn.run(write(tmp, 'ng.last', NG_LINE + '\n'), datalink=link, state_path=state(tmp))
101
155
  p = link.sent[0]
102
156
  q = p['question']
103
157
  # (c) 管制が本文として取り出す見出し語(ai-kana: NOTICE_BODY_LABELS)。
@@ -109,7 +163,9 @@ with tempfile.TemporaryDirectory() as tmp:
109
163
  # 失敗の理由・時刻・版が読める=(2) 適用version/時刻を確認可能にする。
110
164
  check_true('うまくいかなかった理由が載る', 'write-failed' in q)
111
165
  check_true('最後に試した時刻が載る', '2026-09-16T11:00:00+0900' in q)
112
- check_true('配ろうとした版が載る', '0.12.7' in q)
166
+ check_true('入っている版が載る', '入っている版: 0.12.7' in q)
167
+ # ★控えに公開の版が無い種類では、その行を出さない(「不明」と書いて読む人を惑わせない)。
168
+ check('控えに無い「公開の版」は行ごと出さない', '公開の版' in q, False)
113
169
  # (d) ボタンを1つも出さない形(kind=message かつ choices なし)。
114
170
  check('種別は message', p['kind'], 'message')
115
171
  check('選択肢を付けない(押せないボタンを置かない)', 'choices' in p, False)
@@ -119,7 +175,7 @@ with tempfile.TemporaryDirectory() as tmp:
119
175
  # (e) 鍵が機体ごとに固定。
120
176
  check_true('鍵が付いている', p['requestKey'].startswith(sn.REQUEST_KEY_PREFIX + ':'))
121
177
  link2 = FakeLink()
122
- sn.run(write(tmp, 'ng2.last', NG_LINE + '\n'), datalink=link2)
178
+ sn.run(write(tmp, 'ng2.last', NG_LINE + '\n'), datalink=link2, state_path=state(tmp, 'b.json'))
123
179
  check('2回目も同じ鍵(カードが増えない)', link2.sent[0]['requestKey'], p['requestKey'])
124
180
  # ★控えの中身(パス)以外の余計な物を載せない=秘密は1文字も入らない。
125
181
  check('本文にトークンは入らない', CFG['token'] in q, False)
@@ -128,9 +184,11 @@ with tempfile.TemporaryDirectory() as tmp:
128
184
  print('\n【★2種類目=配り元の配り損ね(便 4061dd71)】')
129
185
  DIST_NG = ('2026-09-16T12:00:00+0900 status=failed reason=version-drift what=dist-source '
130
186
  'version=0.12.6 published=0.12.8 root=/n/@tyhld/conductor')
187
+ DIST_OK = ('2026-09-16T13:00:00+0900 status=ok reason=verified what=dist-source '
188
+ 'version=0.12.8 published=0.12.8 root=/n/@tyhld/conductor')
131
189
  with tempfile.TemporaryDirectory() as tmp:
132
190
  link = FakeLink()
133
- sn.run(write(tmp, 'dist.last', DIST_NG + '\n'), datalink=link)
191
+ sn.run(write(tmp, 'dist.last', DIST_NG + '\n'), datalink=link, state_path=state(tmp))
134
192
  p = link.sent[0]
135
193
  q = p['question']
136
194
  check_true('見出しが配り元の話になっている', '安全装置を配れていません' in p['summary'])
@@ -138,35 +196,181 @@ with tempfile.TemporaryDirectory() as tmp:
138
196
  check_true('本文に「再開のしかた」がある', '再開のしかた:' in q)
139
197
  check_true('何を配れていないかが書いてある', '番人' in q and '権限ルール' in q)
140
198
  check_true('うまくいかなかった理由が載る', 'version-drift' in q)
199
+ check_true('入っている版と公開の版が並ぶ', '入っている版: 0.12.6' in q and '公開の版: 0.12.8' in q)
141
200
  check_true('鍵が種類ごとに分かれている', p['requestKey'].startswith('dist-source-unverified:'))
142
201
  # ★鉄則の配り損ねとは別の鍵=2種類が同じ1枚に潰れない。
143
202
  link2 = FakeLink()
144
- sn.run(write(tmp, 'skill.last', NG_LINE + '\n'), datalink=link2)
203
+ sn.run(write(tmp, 'skill.last', NG_LINE + '\n'), datalink=link2, state_path=state(tmp, 'b.json'))
145
204
  check('鉄則の配り損ねとは別の鍵', p['requestKey'] == link2.sent[0]['requestKey'], False)
146
205
  # ★知らない種類は、従来どおり鉄則の配り損ねとして扱う(増やしても壊れない)。
147
206
  link3 = FakeLink()
148
207
  unknown = DIST_NG.replace('what=dist-source', 'what=まだ無い種類')
149
- sn.run(write(tmp, 'unknown.last', unknown + '\n'), datalink=link3)
208
+ sn.run(write(tmp, 'unknown.last', unknown + '\n'), datalink=link3, state_path=state(tmp, 'c.json'))
150
209
  check_true('知らない種類でも落ちずにカードは出る', bool(link3.sent))
151
210
  check_true('知らない種類は従来の文面へ倒れる',
152
211
  '職人の決まりが古いままです' in link3.sent[0]['summary'])
153
212
 
213
+ # ── (h) 本文は【いつもその回の値】=閉じてから出し直す(便 675e2d05)──────────
214
+ print('\n【(h) 失敗の回は前の札を閉じてから出し直す】')
215
+ DRIFT_OLD = ('2026-09-16T12:00:00+0900 status=failed reason=version-drift what=dist-source '
216
+ 'version=0.12.6 published=0.12.8 root=/n/@tyhld/conductor')
217
+ DRIFT_NEW = ('2026-09-20T09:00:00+0900 status=failed reason=version-drift what=dist-source '
218
+ 'version=0.12.6 published=0.12.31 root=/n/@tyhld/conductor')
219
+ with tempfile.TemporaryDirectory() as tmp:
220
+ st = state(tmp)
221
+ link = FakeLink(ids=['req-old', 'req-new'])
222
+ check('1回目の失敗: 札を出す',
223
+ sn.run(write(tmp, 'a.last', DRIFT_OLD + '\n'), datalink=link, state_path=st), 'req-old')
224
+ check('1回目は閉じに行かない(まだ何も出ていない)', link.closed, [])
225
+
226
+ check('2回目の失敗: 別のIDで出し直す',
227
+ sn.run(write(tmp, 'b.last', DRIFT_NEW + '\n'), datalink=link, state_path=st), 'req-new')
228
+ check('2回目は前の札を閉じた', link.closed, ['req-old'])
229
+ # ★順序が肝。出し直してから閉じると、閉じたのが新しい札になって何も残らない。
230
+ check('★閉じる→出し直す の順である', link.log[1:], [('close', 'req-old'),
231
+ ('register', link.sent[1]['requestKey'])])
232
+ q2 = link.sent[1]['question']
233
+ check_true('本文がその回の値になっている', '公開の版: 0.12.31' in q2)
234
+ check('前の回の値が本文に残らない', '0.12.8' in q2, False)
235
+ check_true('時刻もその回のもの', '2026-09-20T09:00:00+0900' in q2)
236
+ check('覚えているのは最後に出した札だけ',
237
+ json.load(open(st, encoding='utf-8')), {'dist-source-unverified': 'req-new'})
238
+
239
+ # ★空振り: 閉じる手当てを外すと、前の札が管制に残ったまま=本文が古いまま延命される。
240
+ orig_close = sn._close
241
+ sn._close = lambda *a, **k: False
242
+ try:
243
+ link2 = FakeLink(ids=['req-x'])
244
+ sn.run(write(tmp, 'c.last', DRIFT_NEW + '\n'), datalink=link2, state_path=st)
245
+ check('★空振り: 閉じなくすると1枚も閉じられない', link2.closed, [])
246
+ finally:
247
+ sn._close = orig_close
248
+
249
+ # ── (i) 直ったら札を閉じる ──────────────────────────────────────────────────
250
+ print('\n【(i) 成功の回は出していた札を閉じる】')
251
+ with tempfile.TemporaryDirectory() as tmp:
252
+ st = state(tmp)
253
+ link = FakeLink(ids=['req-1'])
254
+ sn.run(write(tmp, 'ng.last', DRIFT_OLD + '\n'), datalink=link, state_path=st)
255
+ check('直る前: 札が1枚出ている', link.sent and len(link.sent), 1)
256
+
257
+ check('成功の控え: 新しい札は出さない',
258
+ sn.run(write(tmp, 'ok.last', DIST_OK + '\n'), datalink=link, state_path=st), None)
259
+ check('成功の控え: 出ていた札を閉じた', link.closed, ['req-1'])
260
+ check('成功の控え: 送ったのは最初の1枚だけ', len(link.sent), 1)
261
+ check('閉じた札は覚え書きから外れる', json.load(open(st, encoding='utf-8')), {})
262
+
263
+ # 2度目の成功では、もう閉じるものが無い=管制へ1回も触らない。
264
+ link2 = FakeLink()
265
+ sn.run(write(tmp, 'ok2.last', DIST_OK + '\n'), datalink=link2, state_path=st)
266
+ check('直ったあとの毎時の成功: 管制へ触らない', link2.log, [])
267
+
268
+ # ★種類ごとに別々に覚える=配り元が直っても鉄則の札は閉じない。
269
+ st2 = state(tmp, 'two.json')
270
+ link3 = FakeLink(ids=['req-dist', 'req-skill'])
271
+ sn.run(write(tmp, 'd.last', DRIFT_OLD + '\n'), datalink=link3, state_path=st2)
272
+ sn.run(write(tmp, 's.last', NG_LINE + '\n'), datalink=link3, state_path=st2)
273
+ link3.closed.clear()
274
+ sn.run(write(tmp, 'do.last', DIST_OK + '\n'), datalink=link3, state_path=st2)
275
+ check('配り元の成功で閉じるのは配り元の札だけ', link3.closed, ['req-dist'])
276
+ check('鉄則の札は覚えたまま(まだ直っていない)',
277
+ json.load(open(st2, encoding='utf-8')), {'craftsman-skill-stale': 'req-skill'})
278
+
279
+ # ── (j) 再開の手順が理由に合っている ────────────────────────────────────────
280
+ print('\n【(j) 再開の手順が、うまくいかなかった理由に合っている】')
281
+ with tempfile.TemporaryDirectory() as tmp:
282
+ def body_for(reason, what='dist-source'):
283
+ line = (f'2026-09-20T09:00:00+0900 status=failed reason={reason} what={what} '
284
+ 'version=0.12.6 published=0.12.31 root=/n/@tyhld/conductor')
285
+ link = FakeLink()
286
+ sn.run(write(tmp, f'{reason}.last', line + '\n'), datalink=link,
287
+ state_path=state(tmp, f'{reason}.json'))
288
+ return link.sent[0]['question']
289
+
290
+ drift = body_for('version-drift')
291
+ # ★版がずれているときに配り直しだけを促すと、配り元の確認でまた弾かれる=直らない。
292
+ check_true('版ずれ: 入れ直しを先に案内する', '① npm install -g @tyhld/conductor' in drift)
293
+ check_true('版ずれ: 取得先まで書いてある(社内の取得先を見に行かせない)',
294
+ '--@tyhld:registry=https://registry.npmjs.org' in drift)
295
+ check_true('版ずれ: そのあと配り直しを案内する', '② APPLY=1 bash' in drift)
296
+ check_true('版ずれ: 入れ直しが配り直しより先に書いてある',
297
+ drift.index('npm install -g') < drift.index('APPLY=1'))
298
+
299
+ check_true('入っていない: 入れ直しから案内する', 'npm install -g' in body_for('not-installed'))
300
+ check_true('中身が欠けている: 入れ直しから案内する', 'npm install -g' in body_for('incomplete'))
301
+
302
+ net = body_for('unverifiable')
303
+ check_true('公開npmに聞けない: 先に通信を確かめさせる', '通信' in net)
304
+ check('公開npmに聞けない: 入れ直しは案内しない(入っている版は正しいかもしれない)',
305
+ 'npm install -g' in net, False)
306
+ check_true('公開npmに聞けない: 配り直しの1行は載る', 'APPLY=1 bash' in net)
307
+
308
+ plain = body_for('write-failed', what='craftsman-skill')
309
+ check('書けなかった: 入れ直しは案内しない', 'npm install -g' in plain, False)
310
+ check_true('書けなかった: 配り直しの1行だけ', 'APPLY=1 bash' in plain)
311
+
312
+ # ★どの理由でも、打たせるのは【入っている配布物の台本】=作業ツリーの台本ではない。
313
+ for label, b in (('版ずれ', drift), ('通信不能', net), ('書込失敗', plain)):
314
+ check_true(f'{label}: 入っている配布物の台本を打たせる', 'npm root -g' in b)
315
+ check(f'{label}: 作業ツリーへ cd させない', 'cd ~/projects' in b, False)
316
+
317
+ # ── ★札の覚え書き(壊れていても・書けなくても止まらない)──────────────────
318
+ print('\n【★札の覚え書き】')
319
+ with tempfile.TemporaryDirectory() as tmp:
320
+ broken = write(tmp, 'broken.json', '{ こわれている')
321
+ link = FakeLink()
322
+ check('覚え書きが壊れている: 落ちずに新しく出す',
323
+ sn.run(write(tmp, 'ng.last', NG_LINE + '\n'), datalink=link, state_path=broken), 'req-1')
324
+ check('覚え書きが壊れている: 閉じに行かない(番号が分からない)', link.closed, [])
325
+
326
+ # 配列など、想定外の形が入っていても辞書として扱わない。
327
+ odd = write(tmp, 'odd.json', '[1, 2, 3]')
328
+ check('覚え書きが辞書でない: 空として扱う', sn._load_cards(odd), {})
329
+
330
+ # 書けない場所でも止まらない(親がファイル=ディレクトリを作れない)。
331
+ blocked = os.path.join(write(tmp, 'afile', 'x'), 'cards.json')
332
+ link2 = FakeLink()
333
+ check('覚え書きを書けない: それでも知らせる',
334
+ sn.run(write(tmp, 'ng2.last', NG_LINE + '\n'), datalink=link2, state_path=blocked),
335
+ 'req-1')
336
+
337
+ # ★閉じるのに失敗しても、出し直しは続ける(古い札が残るより無い方がまし、ではなく
338
+ # ★新しい本文を必ず出す方を優先する)。
339
+ st = state(tmp, 'x.json')
340
+ link3 = FakeLink(ids=['req-1', 'req-2'], abandon_raises=True)
341
+ sn.run(write(tmp, 'a.last', NG_LINE + '\n'), datalink=link3, state_path=st)
342
+ check('閉じるのが失敗しても出し直す',
343
+ sn.run(write(tmp, 'b.last', NG_LINE + '\n'), datalink=link3, state_path=st), 'req-2')
344
+
345
+ # ★覚え書きに入るのは番号だけ(秘密は1文字も入らない)。
346
+ saved = open(st, encoding='utf-8').read()
347
+ check('覚え書きにトークンは入らない', CFG['token'] in saved, False)
348
+ check('覚え書きに入るのは鍵と番号だけ', json.loads(saved), {'craftsman-skill-stale': 'req-2'})
349
+
154
350
  # ── (f) 知らせに失敗しても同期は止めない ────────────────────────────────────
155
351
  print('\n【(f) 知らせに失敗しても同期を止めない】')
156
352
  with tempfile.TemporaryDirectory() as tmp:
157
353
  last = write(tmp, 'ng.last', NG_LINE + '\n')
354
+ st = state(tmp)
158
355
 
159
356
  # 接続情報が無い機体。
160
- check('接続情報が無い: 何もせず終わる', sn.run(last, datalink=FakeLink(cfg=None)), None)
357
+ check('接続情報が無い: 何もせず終わる',
358
+ sn.run(last, datalink=FakeLink(cfg=None), state_path=st), None)
161
359
  # 管制が受け取らなかった。
162
- check('登録が空返事: 何もせず終わる', sn.run(last, datalink=FakeLink(reg=None)), None)
163
- check('登録にIDが無い: 何もせず終わる', sn.run(last, datalink=FakeLink(reg={})), None)
360
+ check('登録が空返事: 何もせず終わる',
361
+ sn.run(last, datalink=FakeLink(reg=None), state_path=st), None)
362
+ check('登録にIDが無い: 何もせず終わる',
363
+ sn.run(last, datalink=FakeLink(reg={}), state_path=st), None)
364
+ check('受け取られなかった札は覚えない', os.path.exists(st), False)
164
365
 
165
366
  # ★main() は何があっても 0 を返す(update-guard.sh が止まらない)。
166
367
  argv = sys.argv
167
368
  sys.argv = ['skill_sync_notify.py', last]
168
369
  orig_load, orig_reg = sn.hook_datalink.load_config, sn.hook_datalink.register
370
+ orig_state = sn.CARD_STATE
169
371
  try:
372
+ # ★本物の置き場へ書かせない(試験が実機の覚え書きを汚さない)。
373
+ sn.CARD_STATE = state(tmp, 'main.json')
170
374
  sn.hook_datalink.load_config = lambda env=None: CFG
171
375
 
172
376
  def boom(cfg, payload):
@@ -176,6 +380,7 @@ with tempfile.TemporaryDirectory() as tmp:
176
380
  check('通信が落ちても exit 0', sn.main(), 0)
177
381
  finally:
178
382
  sn.hook_datalink.load_config, sn.hook_datalink.register = orig_load, orig_reg
383
+ sn.CARD_STATE = orig_state
179
384
  sys.argv = argv
180
385
  check('引数が無くても exit 0', (lambda: (sys.argv.__setitem__(slice(None), ['x']), sn.main())[1])(), 0)
181
386
  sys.argv = argv
@@ -183,17 +388,23 @@ with tempfile.TemporaryDirectory() as tmp:
183
388
  # ── ★空振り確認 ─────────────────────────────────────────────────────────────
184
389
  print('\n【★空振り確認】')
185
390
  with tempfile.TemporaryDirectory() as tmp:
391
+ st = state(tmp)
186
392
  # 「status を見ずに必ず出す」ようにしたら (a) が落ちる=(a) は本当に効いている。
187
393
  orig_parse = sn.parse_last
188
394
  sn.parse_last = lambda p: dict(orig_parse(p), status='failed')
189
395
  try:
190
396
  link = FakeLink()
191
- sn.run(write(tmp, 'ok.last', OK_LINE + '\n'), datalink=link)
397
+ sn.run(write(tmp, 'ok.last', OK_LINE + '\n'), datalink=link, state_path=st)
192
398
  check('★空振り: 判定を壊すと成功の控えでも出てしまう', len(link.sent), 1)
193
399
  finally:
194
400
  sn.parse_last = orig_parse
195
401
  link = FakeLink()
196
- check('★戻したあと: 成功では出ない', sn.run(write(tmp, 'ok2.last', OK_LINE + '\n'), datalink=link), None)
402
+ check('★戻したあと: 成功では出ない',
403
+ sn.run(write(tmp, 'ok2.last', OK_LINE + '\n'), datalink=link, state_path=st), None)
404
+
405
+ # ★試験そのものが実機の覚え書きに触っていないこと(上の state() を1つでも外したら落ちる)。
406
+ # 実機ではこのファイルが在るのが普通なので、「無いこと」ではなく【変わっていないこと】を見る。
407
+ check('試験は本物の覚え書きを書き換えない', real_state_snapshot(), REAL_STATE_BEFORE)
197
408
 
198
409
  print('\n' + '=' * 72)
199
410
  if failures:
@@ -119,9 +119,11 @@ dist_record() {
119
119
  fi
120
120
  }
121
121
 
122
- # 配り損ねを管制へ知らせる(★便 29612ff1 と同じ口・届かなくても止まらない)。
122
+ # 管制の札を、いまの控えに合わせる(★便 29612ff1 と同じ口・届かなくても止まらない)。
123
123
  # ★確認のみのときは知らせない(見ただけでお客さまの画面にカードを出さない)。
124
- dist_notify_failure() {
124
+ # ★失敗の回は札を出し直し、成功の回は札を閉じる(便 675e2d05)。どちらも同じ呼び方で、
125
+ # 知らせる側が控えの status を見て決める=ここに判断を二重に書かない。
126
+ dist_notify() {
125
127
  [[ "$APPLY" == "1" ]] || return 0
126
128
  [[ -f "$DIST_NOTIFY" ]] || return 0
127
129
  python3 "$DIST_NOTIFY" "$DIST_LAST" >/dev/null 2>&1 || true
@@ -138,14 +140,14 @@ if ! dist_source_resolve; then
138
140
  esac
139
141
  warn " 人の手番: npm install -g @tyhld/conductor --@tyhld:registry=https://registry.npmjs.org"
140
142
  dist_record failed "$DIST_REASON"
141
- dist_notify_failure
143
+ dist_notify
142
144
  exit 0
143
145
  fi
144
146
  if ! dist_source_complete "$DIST_ROOT"; then
145
147
  warn "配り元の中身が欠けています: $DIST_ROOT(★1バイトも配りません)"
146
148
  DIST_REASON='incomplete'
147
149
  dist_record failed incomplete
148
- dist_notify_failure
150
+ dist_notify
149
151
  exit 0
150
152
  fi
151
153
  info " 配り元: $DIST_ROOT(版 $DIST_VERSION =公開 npm のいまの版)"
@@ -153,6 +155,9 @@ if [[ "$DIST_ROOT" != "$REPO_ROOT" ]]; then
153
155
  info " ★この台本の置き場($REPO_ROOT)からは配りません。"
154
156
  fi
155
157
  dist_record ok verified
158
+ # ★直ったので、出ていた「安全装置を配れていません」の札を閉じる(便 675e2d05)。
159
+ # 以前は成功しても閉じに行かず、最大60分出たままだった(実地 2026-09-20・便 8692c586)。
160
+ dist_notify
156
161
 
157
162
  # 配る中身は、すべて上で確かめた配り元から取る。
158
163
  GUARD_SRC_DIR="$DIST_ROOT/scripts/hooks" # 番人の正本
@@ -369,32 +374,45 @@ craftsman_backup() {
369
374
  return 0
370
375
  }
371
376
 
372
- # 配り損ねを管制へ知らせる(★届かなくても同期は止めない)。
373
- craftsman_notify_failure() {
377
+ # 管制の札を、いまの控えに合わせる(★届かなくても同期は止めない)。
378
+ # ★失敗の回は札を出し直し、成功の回は札を閉じる(便 675e2d05)。どちらも同じ呼び方で、
379
+ # 知らせる側が控えの status を見て決める=ここに判断を二重に書かない。
380
+ craftsman_notify() {
374
381
  [[ -f "$CRAFTSMAN_NOTIFY" ]] || return 0
375
382
  python3 "$CRAFTSMAN_NOTIFY" "$CRAFTSMAN_LAST" >/dev/null 2>&1 || true
376
383
  }
377
384
 
385
+ # 直った回だけの呼び口。★確認のみ(APPLY!=1)のときは管制の札に触らない。
386
+ # 【なぜ分けるか】確認のみは「いまどうなっているかを見るだけ」の操作なので、それで
387
+ # お客さまの画面の札が消える形にしない(配り元の知らせ dist_notify と同じ流儀)。
388
+ # ★失敗の回は従来どおり確認のみでも知らせる(古い決まりのまま動いていることは見せ続ける)。
389
+ craftsman_notify_recovered() {
390
+ [[ "$APPLY" == "1" ]] || return 0
391
+ craftsman_notify
392
+ }
393
+
378
394
  info "①-d 職人の鉄則スキル: $CRAFTSMAN_SKILL_DST"
379
395
  CRAFTSMAN_VER="$(craftsman_skill_version)"
380
396
  CRAFTSMAN_SHA="$(craftsman_skill_sha "$CRAFTSMAN_SKILL_SRC")"
381
397
  if [[ ! -f "$CRAFTSMAN_SKILL_SRC" ]]; then
382
398
  warn "鉄則の正本が見つかりません: $CRAFTSMAN_SKILL_SRC(★古い鉄則のまま動きます)"
383
399
  craftsman_record failed src-missing "$CRAFTSMAN_VER" none
384
- craftsman_notify_failure
400
+ craftsman_notify
385
401
  elif [[ ! -s "$CRAFTSMAN_SKILL_SRC" ]]; then
386
402
  warn "鉄則の正本が空です: $CRAFTSMAN_SKILL_SRC(★古い鉄則のまま動きます)"
387
403
  craftsman_record failed src-empty "$CRAFTSMAN_VER" none
388
- craftsman_notify_failure
404
+ craftsman_notify
389
405
  elif cmp -s "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null; then
390
406
  info " 最新です(版 ${CRAFTSMAN_VER} / 中身 ${CRAFTSMAN_SHA})"
391
407
  craftsman_record ok already-current "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
408
+ # ★配れているので、出ていた「決まりが古いままです」の札を閉じる(便 675e2d05)。
409
+ craftsman_notify_recovered
392
410
  elif [[ "$APPLY" == "1" ]]; then
393
411
  # ★消える物が在るなら、上書きの【前】に控えを取る。取れなければ上書きしない。
394
412
  if ! craftsman_backup; then
395
413
  warn "上書きの前の控えを取れませんでした: $CRAFTSMAN_BACKUP_DIR(★鉄則は上書きしません)"
396
414
  craftsman_record failed backup-failed "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
397
- craftsman_notify_failure
415
+ craftsman_notify
398
416
  elif mkdir -p "$(dirname "$CRAFTSMAN_SKILL_DST")" 2>/dev/null \
399
417
  && cp -f "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null \
400
418
  && cmp -s "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null; then
@@ -403,10 +421,12 @@ elif [[ "$APPLY" == "1" ]]; then
403
421
  fi
404
422
  info " 更新しました(版 ${CRAFTSMAN_VER} / 中身 ${CRAFTSMAN_SHA}・この1台の全現場が共有します)"
405
423
  craftsman_record ok applied "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
424
+ # ★直ったので札を閉じる。以前は成功しても閉じに行かず、最大60分出たままだった。
425
+ craftsman_notify_recovered
406
426
  else
407
427
  warn "鉄則を配れませんでした: $CRAFTSMAN_SKILL_DST(★古い鉄則のまま動きます)"
408
428
  craftsman_record failed write-failed "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
409
- craftsman_notify_failure
429
+ craftsman_notify
410
430
  fi
411
431
  else
412
432
  info " (予定)正本の内容へ揃えます(版 ${CRAFTSMAN_VER} / 中身 ${CRAFTSMAN_SHA})"
@@ -50,5 +50,21 @@ ExecStart=__CONDUCTOR_BIN__ build-runner
50
50
  Restart=always
51
51
  RestartSec=30
52
52
 
53
+ # ★メモリが足りなくなったときに、真っ先に止められないようにする(便 85a9fb64・メモリ対策B)。
54
+ # 【なぜ要るか(実測 2026-09-19・調査 5286ffd5)】機体のメモリが尽きたとき、カーネルは
55
+ # 「使っている量+落ちやすさの下駄」で止める相手を選ぶ。利用者の常駐は systemd の既定
56
+ # (DefaultOOMScoreAdjust=200)で下駄を履かされており、200 は「機体の全メモリの20%ぶん
57
+ # 余計に使っている」のと同じ重み=15.8GB の機体では約3.2GB の下駄になる。
58
+ # その結果、40MB しか使っていないこの常駐が「3.2GB のプロセス」と見なされ、
59
+ # 本当に重い相手(職人の build・lint が 1〜4.7GB)より先に止められた。実際 2026-09-19 に
60
+ # 常駐18本がまとめて止められ、配達が止まった。
61
+ # 【何をするか】下駄を外して素の値(0)に戻すだけ。特別扱いはしない。
62
+ # =重い相手が先に止められ、配達役は生き残る。★常駐自身が膨らんだ場合は
63
+ # ふつうに止められる(守りすぎて機体ごと固まる形にしない)。
64
+ # 【なぜ負の値にしないか(実測)】権限のない利用者のプロセスは、落ちやすさを
65
+ # 0 より下げられない(-100 を書くと Permission denied)。負の値を書くと常駐が
66
+ # 起動できなくなるので、下限である 0 にする。
67
+ OOMScoreAdjust=0
68
+
53
69
  [Install]
54
70
  WantedBy=default.target
@@ -59,5 +59,21 @@ ExecStart=__CONDUCTOR_BIN__ ear
59
59
  Restart=always
60
60
  RestartSec=10
61
61
 
62
+ # ★メモリが足りなくなったときに、真っ先に止められないようにする(便 85a9fb64・メモリ対策B)。
63
+ # 【なぜ要るか(実測 2026-09-19・調査 5286ffd5)】機体のメモリが尽きたとき、カーネルは
64
+ # 「使っている量+落ちやすさの下駄」で止める相手を選ぶ。利用者の常駐は systemd の既定
65
+ # (DefaultOOMScoreAdjust=200)で下駄を履かされており、200 は「機体の全メモリの20%ぶん
66
+ # 余計に使っている」のと同じ重み=15.8GB の機体では約3.2GB の下駄になる。
67
+ # その結果、40MB しか使っていないこの常駐が「3.2GB のプロセス」と見なされ、
68
+ # 本当に重い相手(職人の build・lint が 1〜4.7GB)より先に止められた。実際 2026-09-19 に
69
+ # 常駐18本がまとめて止められ、配達が止まった。
70
+ # 【何をするか】下駄を外して素の値(0)に戻すだけ。特別扱いはしない。
71
+ # =重い相手が先に止められ、配達役は生き残る。★常駐自身が膨らんだ場合は
72
+ # ふつうに止められる(守りすぎて機体ごと固まる形にしない)。
73
+ # 【なぜ負の値にしないか(実測)】権限のない利用者のプロセスは、落ちやすさを
74
+ # 0 より下げられない(-100 を書くと Permission denied)。負の値を書くと常駐が
75
+ # 起動できなくなるので、下限である 0 にする。
76
+ OOMScoreAdjust=0
77
+
62
78
  [Install]
63
79
  WantedBy=default.target
@@ -58,5 +58,21 @@ ExecStart=__CONDUCTOR_BIN__ start -s %i -t working -a "%i常駐"
58
58
  Restart=always
59
59
  RestartSec=5
60
60
 
61
+ # ★メモリが足りなくなったときに、真っ先に止められないようにする(便 85a9fb64・メモリ対策B)。
62
+ # 【なぜ要るか(実測 2026-09-19・調査 5286ffd5)】機体のメモリが尽きたとき、カーネルは
63
+ # 「使っている量+落ちやすさの下駄」で止める相手を選ぶ。利用者の常駐は systemd の既定
64
+ # (DefaultOOMScoreAdjust=200)で下駄を履かされており、200 は「機体の全メモリの20%ぶん
65
+ # 余計に使っている」のと同じ重み=15.8GB の機体では約3.2GB の下駄になる。
66
+ # その結果、40MB しか使っていないこの常駐が「3.2GB のプロセス」と見なされ、
67
+ # 本当に重い相手(職人の build・lint が 1〜4.7GB)より先に止められた。実際 2026-09-19 に
68
+ # 常駐18本がまとめて止められ、配達が止まった。
69
+ # 【何をするか】下駄を外して素の値(0)に戻すだけ。特別扱いはしない。
70
+ # =重い相手が先に止められ、配達役は生き残る。★常駐自身が膨らんだ場合は
71
+ # ふつうに止められる(守りすぎて機体ごと固まる形にしない)。
72
+ # 【なぜ負の値にしないか(実測)】権限のない利用者のプロセスは、落ちやすさを
73
+ # 0 より下げられない(-100 を書くと Permission denied)。負の値を書くと常駐が
74
+ # 起動できなくなるので、下限である 0 にする。
75
+ OOMScoreAdjust=0
76
+
61
77
  [Install]
62
78
  WantedBy=default.target