@tyhld/conductor 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/relay.js +125 -12
  2. package/package.json +1 -1
package/dist/relay.js CHANGED
@@ -57,7 +57,30 @@ function envPositiveNumber(name, def) {
57
57
  return Number.isFinite(parsed) && parsed > 0 ? parsed : def;
58
58
  }
59
59
  /**
60
- * 生存の見張り(keepalive)を打ち切るまでの上限(ms)。既定30分=中央のタイムアウトと同じ長さ。
60
+ * ★中央(devlog-tracker)が「手が挙がっていても必ず現場を解放する」上限(ms)
61
+ *
62
+ * 【この数字の持ち主は中央】便1 PR#250 で中央がこう決めた:
63
+ * 「busy の印が新しい間は30分の時間切れをしない。ただし着信から3時間で必ず解放する
64
+ * (手を挙げ直しても伸びない)」。
65
+ * =relay がどれだけ手を挙げ続けても、3時間を過ぎれば現場は解放される。
66
+ *
67
+ * 【★片方だけ動かさないこと】中央がこの値を変えたら、ここも合わせる。
68
+ * 合っていないと次のどちらかになる:
69
+ * ・ここが短い … 中央はまだ守ってくれるのに職人が先に手を下ろす=割り込まれる
70
+ * (2026-09-04 に実際に起きた。中央3時間・relay 30分だった)
71
+ * ・ここが長い … 中央は既に解放しているのに職人だけ手を挙げ続ける=意味がない
72
+ * ★長い側は下の clampToCentralHold が構造的に潰す(設定を間違えても中央を超えない)。
73
+ */
74
+ export const CENTRAL_BUSY_HOLD_MAX_MS = 3 * 60 * 60 * 1000; // 3時間(中央 PR#250 と同じ値)
75
+ /**
76
+ * 見張りの上限を中央の上限に収める。★中央より長い値は意味がないので切り詰める。
77
+ * 環境変数で誤って長く設定しても、中央を超えないことをここで保証する。
78
+ */
79
+ export function clampToCentralHold(ms) {
80
+ return Math.min(ms, CENTRAL_BUSY_HOLD_MAX_MS);
81
+ }
82
+ /**
83
+ * 生存の見張り(keepalive)を打ち切るまでの上限(ms)。★既定は中央と同じ3時間。
61
84
  *
62
85
  * 【なぜ pending とは別の寿命が要るのか(調査 176aebcf・案①-a)】
63
86
  * 上の RELAY_PENDING_TIMEOUT_MS は「配達の追跡」を諦める時計で、固着して次の指示を拾えなく
@@ -70,11 +93,23 @@ function envPositiveNumber(name, def) {
70
93
  * そこで「配達の追跡(pending)」と「生存の見張り(keepalive)」の寿命を分ける。pending は従来
71
94
  * どおり15分で解除して次の指示を拾えるようにし、生存通知だけをこの時計で続ける。
72
95
  *
73
- * 【なぜ30分か】中央が sent/stalled を timeout(=諦めて現場を解放)に落とすのと同じ長さ。
74
- * これより長く見張っても、中央は既に別の指示を配れる状態になっており意味がない。
75
- * 環境変数 RELAY_KEEPALIVE_MAX_MS で上書き可(正の数のみ)。
96
+ * 【なぜ30分→3時間へ広げたか(えふさん決定 2026-09-04)】
97
+ * 便2で「作業中です」と手を挙げる仕掛けを入れたが、この上限が30分のままだと
98
+ * 30分で手が下りて、また割り込まれる。実測 2026-09-04: 15分の pending 解除が18件=
99
+ * 30分を超える便が常態で、割り込まれた便は着信から35分だった。
100
+ * ★中央だけ3時間に伸びていて、職人側が30分で足を引っ張っている状態だったので合わせる。
101
+ *
102
+ * 【★引き受けたリスク(えふさん判断)】職人が固まった場合、その現場は最大3時間ふさがる。
103
+ * だから「時間で下ろす」以外の下ろし方が生命線になる。次の4つは時間を待たずに効く:
104
+ * ① 画面の DONE を検出した (decideKeepalive の 'done')
105
+ * ② 中央で終端になった ('probe' → isCentralFinished)
106
+ * ③ 職人が静止した ('probe' → 手を下ろす。終端でなくても下ろす)
107
+ * ④ relayTick 入口の砦 (pending でも keepalive でもなくなったら必ず下ろす)
108
+ * ★この4つはテスト(test/relay-busy-hold.test.ts)で固定してある。外すと落ちる。
109
+ *
110
+ * 環境変数 RELAY_KEEPALIVE_MAX_MS で上書き可(正の数のみ・★中央の上限までに切り詰める)。
76
111
  */
77
- export const RELAY_KEEPALIVE_MAX_MS = envPositiveNumber('RELAY_KEEPALIVE_MAX_MS', 30 * 60 * 1000);
112
+ export const RELAY_KEEPALIVE_MAX_MS = clampToCentralHold(envPositiveNumber('RELAY_KEEPALIVE_MAX_MS', CENTRAL_BUSY_HOLD_MAX_MS));
78
113
  /**
79
114
  * 自動再送(2-a)のポリシー。生存中の職人が動かないときに同一指示を再送する。
80
115
  * - RELAY_RESEND_DELAY_MS: 初回送信(sentAt)から最初の再送までの待機(ms)。既定30秒。
@@ -183,6 +218,7 @@ export function createRelayState() {
183
218
  deliverCounts: new Map(),
184
219
  abandoned: new Set(),
185
220
  promptPending: false,
221
+ busyRaisedId: null,
186
222
  };
187
223
  }
188
224
  /**
@@ -1391,9 +1427,18 @@ export function decideHeartbeat(alive, throttleOk) {
1391
1427
  * (=/seen の perm 系)。画面から質問を読む処理ごと撤去したため、送るのは body 無しの生存通知だけ。
1392
1428
  * 確認そのものは PermissionRequest フックが別の口(hook-requests)でデータとして届ける。
1393
1429
  */
1394
- export async function sendHeartbeat(cfg, id) {
1430
+ export async function sendHeartbeat(cfg, id, busy = false) {
1395
1431
  try {
1396
- const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, { method: 'PATCH', headers: authHeaders(cfg) });
1432
+ // ★busy が【真偽値の true】のときだけ body を載せる。それ以外は今までと1バイトも変えない
1433
+ // (body 無しの PATCH)。中央も "true" や 1 のような真偽値でない値は無視する仕様。
1434
+ const init = busy === true
1435
+ ? {
1436
+ method: 'PATCH',
1437
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
1438
+ body: JSON.stringify({ busy: true }),
1439
+ }
1440
+ : { method: 'PATCH', headers: authHeaders(cfg) };
1441
+ const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, init);
1397
1442
  if (!res.ok) {
1398
1443
  log(`site=${cfg.site} id=${id} heartbeat -> HTTP ${res.status} NG(次回再試行・縮退で安全)`);
1399
1444
  }
@@ -1402,6 +1447,45 @@ export async function sendHeartbeat(cfg, id) {
1402
1447
  log(`site=${cfg.site} id=${id} heartbeat -> 失敗: ${err instanceof Error ? err.message : String(err)}(縮退で安全)`);
1403
1448
  }
1404
1449
  }
1450
+ /**
1451
+ * いま「作業中です」と手を挙げる(busy:true を載せる)かを決める純粋関数。
1452
+ *
1453
+ * 【★手を挙げるのは「手を動かしている」印があるときだけ】
1454
+ * 使うのは busy シグナル(入力枠フッターの `esc to interrupt` / スピナー=hasBusySignal)。
1455
+ * これは「職人がいま処理を回している」ことを画面から直に読める、いちばん確かな印である。
1456
+ *
1457
+ * 【★選択メニュー中は挙げない】
1458
+ * メニューは【人の答え待ち】で、職人の手は止まっている。ここで手を挙げ続けると、
1459
+ * 誰も答えないまま現場が長時間ふさがる。生存通知(lastSeenAt)は従来どおりメニュー中も
1460
+ * 送るので、「落ちた職人」と誤認されることはない=退行しない。
1461
+ *
1462
+ * 【★迷ったら挙げない】
1463
+ * busy とメニューが同時に見えるような読み取りは、どちらとも言い切れない。挙げなければ
1464
+ * 従来どおり(中央は30分で解放)=いまと同じ動きに戻るだけなので、そちらへ倒す。
1465
+ */
1466
+ export function shouldRaiseBusy(busySignal, menuOpen) {
1467
+ if (menuOpen)
1468
+ return false;
1469
+ return busySignal === true;
1470
+ }
1471
+ /**
1472
+ * 手を挙げた/下ろしたの【変わり目だけ】をログに出す。状態は state.busyRaisedId が持つ。
1473
+ * ★毎回のハートビートで出すと現場のログが埋まるので、変わったときだけ1行。
1474
+ * ★秘密は出さない(現場名と指示idだけ)。
1475
+ */
1476
+ export function noteBusyEdge(state, site, id, raise, logLine) {
1477
+ if (raise) {
1478
+ if (state.busyRaisedId !== id) {
1479
+ state.busyRaisedId = id;
1480
+ logLine(`site=${site} id=${id} 作業中の手を挙げました(中央の時間切れを保留させます)`);
1481
+ }
1482
+ return;
1483
+ }
1484
+ if (state.busyRaisedId === id) {
1485
+ state.busyRaisedId = null;
1486
+ logLine(`site=${site} id=${id} 作業中の手を下ろしました(中央は従来どおり時間で解放します)`);
1487
+ }
1488
+ }
1405
1489
  // ───────────────────────────────────────────────────────────────────────────
1406
1490
  // 生存の見張り(keepalive)— 配達の追跡(pending)とは別の寿命で回す(調査 176aebcf・案①-a)
1407
1491
  //
@@ -1468,7 +1552,7 @@ export function decideKeepalive(input) {
1468
1552
  }
1469
1553
  const DEFAULT_KEEPALIVE_DEPS = {
1470
1554
  capture: (site) => tmuxCapture(site),
1471
- heartbeat: (cfg, id) => sendHeartbeat(cfg, id),
1555
+ heartbeat: (cfg, id, busy) => sendHeartbeat(cfg, id, busy === true),
1472
1556
  complete: (cfg, id, report) => patchStatus(cfg, id, 'done', report),
1473
1557
  centralStatus: (cfg, id) => fetchCommandStatus(cfg, id),
1474
1558
  now: () => Date.now(),
@@ -1503,11 +1587,15 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1503
1587
  // ★見張りに入っている=その指示は必ず着信済み(startKeepalive が delivered を要求する)。
1504
1588
  // よってアンカーが流れて消えていても、画面全体を見てよい(ADR-020)。
1505
1589
  const below = cap === null ? null : sliceForDoneScan(cap, ka.anchor, true);
1590
+ // ★「作業中です」の印は busy シグナルだけで決める(メニュー中は挙げない・shouldRaiseBusy)。
1591
+ // 生存通知の可否(alive)は従来どおり busy ∪ メニュー=1文字も変えない。
1592
+ const kaBusySignal = cap !== null && hasBusySignalFor(cap, kindOf(cfg));
1593
+ const kaMenu = cap !== null && hasChoiceMenuFor(cap, kindOf(cfg));
1506
1594
  const action = decideKeepalive({
1507
1595
  expired,
1508
1596
  capOk: cap !== null,
1509
1597
  doneSeen: ka.screenDoneUsable && below !== null && hasStandaloneDone(below, ka.site),
1510
- alive: cap !== null && (hasBusySignalFor(cap, kindOf(cfg)) || hasChoiceMenuFor(cap, kindOf(cfg))),
1598
+ alive: cap !== null && (kaBusySignal || kaMenu),
1511
1599
  idle: cap !== null && looksIdleFor(cap, kindOf(cfg)),
1512
1600
  throttleOk: ka.lastHeartbeatAt === null || now - ka.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS,
1513
1601
  });
@@ -1515,10 +1603,12 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1515
1603
  return;
1516
1604
  if (action === 'heartbeat') {
1517
1605
  ka.lastHeartbeatAt = now;
1606
+ const raise = shouldRaiseBusy(kaBusySignal, kaMenu);
1607
+ noteBusyEdge(state, cfg.site, ka.id, raise, logLine);
1518
1608
  // ★継続のログ。出るのは最大でも RELAY_HEARTBEAT_INTERVAL_MS に1回(スロットルに相乗り)。
1519
1609
  logLine(`site=${cfg.site} id=${ka.id} 見張り継続: 職人は生きています→生存通知` +
1520
1610
  `(配達の追跡は解除済み・経過${Math.round((now - ka.startedAt) / 1000)}s)`);
1521
- await heartbeat(cfg, ka.id);
1611
+ await heartbeat(cfg, ka.id, raise);
1522
1612
  return;
1523
1613
  }
1524
1614
  if (action === 'done') {
@@ -1527,6 +1617,8 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1527
1617
  if (await complete(cfg, ka.id, report)) {
1528
1618
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 画面のDONEを検出し完了を報告` +
1529
1619
  `${report != null ? '(報告添付)' : ''}`);
1620
+ // ★終わったら必ず手を下ろす(挙げっぱなしにすると現場が長くふさがる)。
1621
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1530
1622
  state.keepalive = null;
1531
1623
  }
1532
1624
  // 報告に失敗したら畳まない(次tickで再試行。上限が最終的に打ち切る)。
@@ -1538,13 +1630,20 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1538
1630
  // ★上限は無条件で畳む(無限に送り続けない)。中央の言い分はログに残すだけ。
1539
1631
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 上限${Math.round(RELAY_KEEPALIVE_MAX_MS / 60000)}分に到達` +
1540
1632
  `(中央status=${centralStatus ?? '不明'})`);
1633
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1541
1634
  state.keepalive = null;
1542
1635
  return;
1543
1636
  }
1544
1637
  if (isCentralFinished(centralStatus)) {
1545
1638
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 中央で終端(status=${centralStatus})を確認`);
1639
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1546
1640
  state.keepalive = null;
1547
1641
  }
1642
+ // ★'probe' で終端でなかった場合(職人が静止しているだけ)は生存通知を送らない=
1643
+ // 手も挙げない。ここで挙げっぱなしにすると「止まっている職人」で現場をふさぐ。
1644
+ if (action === 'probe') {
1645
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1646
+ }
1548
1647
  // 終端でない(まだ生きている/中央不達)なら畳まない。職人が静止しているだけなので、
1549
1648
  // 生存通知は送らずに見張りだけ続ける=中央は従来どおり stalled へ落とせる(誤魔化さない)。
1550
1649
  }
@@ -1651,6 +1750,15 @@ export async function relayTick(cfg, state) {
1651
1750
  state.restored = true;
1652
1751
  await restoreOrphans(cfg, state);
1653
1752
  }
1753
+ // ── 手を挙げっぱなしを防ぐ最後の砦(★割り込みの根治・便2/3) ──
1754
+ // 「作業中です」と手を挙げた指示が、もう追跡対象(pending でも keepalive でもない)で
1755
+ // なくなっていたら、必ず下ろす。完了・中央での終端・放棄・再起動のどの道で抜けても、
1756
+ // ここを通れば下りる=★挙げっぱなしで現場が長時間ふさがることが構造的に起きない。
1757
+ if (state.busyRaisedId !== null &&
1758
+ state.pending?.id !== state.busyRaisedId &&
1759
+ state.keepalive?.id !== state.busyRaisedId) {
1760
+ noteBusyEdge(state, cfg.site, state.busyRaisedId, false, log);
1761
+ }
1654
1762
  // ── 生存の見張り(keepalive・pending とは別の寿命) ──
1655
1763
  // pending の上限で「配達の追跡」を畳んだ指示について、職人が生きて見える限り中央へ
1656
1764
  // 生存通知を送り続ける(調査 176aebcf・案①-a)。★ここでは return しない=この下の
@@ -1767,7 +1875,10 @@ export async function relayTick(cfg, state) {
1767
1875
  const nowMs = Date.now();
1768
1876
  const throttleOk = state.lastHeartbeatAt === null ||
1769
1877
  nowMs - state.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS;
1770
- const decision = decideHeartbeat(hasBusySignalFor(cap, kindOf(cfg)) || menu, throttleOk);
1878
+ // ★「作業中です」の印は busy シグナルだけで決める(メニュー中は挙げない・shouldRaiseBusy)。
1879
+ // 生存通知の可否は従来どおり busy ∪ メニュー=1文字も変えない。
1880
+ const busySignal = hasBusySignalFor(cap, kindOf(cfg));
1881
+ const decision = decideHeartbeat(busySignal || menu, throttleOk);
1771
1882
  if (decision === 'bare') {
1772
1883
  state.lastHeartbeatAt = nowMs;
1773
1884
  // ★通らなかった理由を1行残す(PR#88 と同じ作法): 撤去によって「黙って止まる」を作らない。
@@ -1780,7 +1891,9 @@ export async function relayTick(cfg, state) {
1780
1891
  '(relayは答えない。PermissionRequestフックが管制へ渡しているか、' +
1781
1892
  'フック未配線なら職人の端末で直接answerが必要)');
1782
1893
  }
1783
- await sendHeartbeat(cfg, state.pending.id);
1894
+ const raise = shouldRaiseBusy(busySignal, menu);
1895
+ noteBusyEdge(state, cfg.site, state.pending.id, raise, log);
1896
+ await sendHeartbeat(cfg, state.pending.id, raise);
1784
1897
  }
1785
1898
  // ── 生存中の自動再送(2-a)。DONE 未達のとき、未着信+アイドルなら同一指示を再送する ──
1786
1899
  // 冪等性最優先: 着信判定(anchor可視)は cap から即わかる。アイドルの二重チェック
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {