@tyhld/conductor 0.6.0 → 0.7.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 +85 -7
  2. package/package.json +1 -1
package/dist/relay.js CHANGED
@@ -183,6 +183,7 @@ export function createRelayState() {
183
183
  deliverCounts: new Map(),
184
184
  abandoned: new Set(),
185
185
  promptPending: false,
186
+ busyRaisedId: null,
186
187
  };
187
188
  }
188
189
  /**
@@ -1391,9 +1392,18 @@ export function decideHeartbeat(alive, throttleOk) {
1391
1392
  * (=/seen の perm 系)。画面から質問を読む処理ごと撤去したため、送るのは body 無しの生存通知だけ。
1392
1393
  * 確認そのものは PermissionRequest フックが別の口(hook-requests)でデータとして届ける。
1393
1394
  */
1394
- export async function sendHeartbeat(cfg, id) {
1395
+ export async function sendHeartbeat(cfg, id, busy = false) {
1395
1396
  try {
1396
- const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, { method: 'PATCH', headers: authHeaders(cfg) });
1397
+ // ★busy が【真偽値の true】のときだけ body を載せる。それ以外は今までと1バイトも変えない
1398
+ // (body 無しの PATCH)。中央も "true" や 1 のような真偽値でない値は無視する仕様。
1399
+ const init = busy === true
1400
+ ? {
1401
+ method: 'PATCH',
1402
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
1403
+ body: JSON.stringify({ busy: true }),
1404
+ }
1405
+ : { method: 'PATCH', headers: authHeaders(cfg) };
1406
+ const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, init);
1397
1407
  if (!res.ok) {
1398
1408
  log(`site=${cfg.site} id=${id} heartbeat -> HTTP ${res.status} NG(次回再試行・縮退で安全)`);
1399
1409
  }
@@ -1402,6 +1412,45 @@ export async function sendHeartbeat(cfg, id) {
1402
1412
  log(`site=${cfg.site} id=${id} heartbeat -> 失敗: ${err instanceof Error ? err.message : String(err)}(縮退で安全)`);
1403
1413
  }
1404
1414
  }
1415
+ /**
1416
+ * いま「作業中です」と手を挙げる(busy:true を載せる)かを決める純粋関数。
1417
+ *
1418
+ * 【★手を挙げるのは「手を動かしている」印があるときだけ】
1419
+ * 使うのは busy シグナル(入力枠フッターの `esc to interrupt` / スピナー=hasBusySignal)。
1420
+ * これは「職人がいま処理を回している」ことを画面から直に読める、いちばん確かな印である。
1421
+ *
1422
+ * 【★選択メニュー中は挙げない】
1423
+ * メニューは【人の答え待ち】で、職人の手は止まっている。ここで手を挙げ続けると、
1424
+ * 誰も答えないまま現場が長時間ふさがる。生存通知(lastSeenAt)は従来どおりメニュー中も
1425
+ * 送るので、「落ちた職人」と誤認されることはない=退行しない。
1426
+ *
1427
+ * 【★迷ったら挙げない】
1428
+ * busy とメニューが同時に見えるような読み取りは、どちらとも言い切れない。挙げなければ
1429
+ * 従来どおり(中央は30分で解放)=いまと同じ動きに戻るだけなので、そちらへ倒す。
1430
+ */
1431
+ export function shouldRaiseBusy(busySignal, menuOpen) {
1432
+ if (menuOpen)
1433
+ return false;
1434
+ return busySignal === true;
1435
+ }
1436
+ /**
1437
+ * 手を挙げた/下ろしたの【変わり目だけ】をログに出す。状態は state.busyRaisedId が持つ。
1438
+ * ★毎回のハートビートで出すと現場のログが埋まるので、変わったときだけ1行。
1439
+ * ★秘密は出さない(現場名と指示idだけ)。
1440
+ */
1441
+ export function noteBusyEdge(state, site, id, raise, logLine) {
1442
+ if (raise) {
1443
+ if (state.busyRaisedId !== id) {
1444
+ state.busyRaisedId = id;
1445
+ logLine(`site=${site} id=${id} 作業中の手を挙げました(中央の時間切れを保留させます)`);
1446
+ }
1447
+ return;
1448
+ }
1449
+ if (state.busyRaisedId === id) {
1450
+ state.busyRaisedId = null;
1451
+ logLine(`site=${site} id=${id} 作業中の手を下ろしました(中央は従来どおり時間で解放します)`);
1452
+ }
1453
+ }
1405
1454
  // ───────────────────────────────────────────────────────────────────────────
1406
1455
  // 生存の見張り(keepalive)— 配達の追跡(pending)とは別の寿命で回す(調査 176aebcf・案①-a)
1407
1456
  //
@@ -1468,7 +1517,7 @@ export function decideKeepalive(input) {
1468
1517
  }
1469
1518
  const DEFAULT_KEEPALIVE_DEPS = {
1470
1519
  capture: (site) => tmuxCapture(site),
1471
- heartbeat: (cfg, id) => sendHeartbeat(cfg, id),
1520
+ heartbeat: (cfg, id, busy) => sendHeartbeat(cfg, id, busy === true),
1472
1521
  complete: (cfg, id, report) => patchStatus(cfg, id, 'done', report),
1473
1522
  centralStatus: (cfg, id) => fetchCommandStatus(cfg, id),
1474
1523
  now: () => Date.now(),
@@ -1503,11 +1552,15 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1503
1552
  // ★見張りに入っている=その指示は必ず着信済み(startKeepalive が delivered を要求する)。
1504
1553
  // よってアンカーが流れて消えていても、画面全体を見てよい(ADR-020)。
1505
1554
  const below = cap === null ? null : sliceForDoneScan(cap, ka.anchor, true);
1555
+ // ★「作業中です」の印は busy シグナルだけで決める(メニュー中は挙げない・shouldRaiseBusy)。
1556
+ // 生存通知の可否(alive)は従来どおり busy ∪ メニュー=1文字も変えない。
1557
+ const kaBusySignal = cap !== null && hasBusySignalFor(cap, kindOf(cfg));
1558
+ const kaMenu = cap !== null && hasChoiceMenuFor(cap, kindOf(cfg));
1506
1559
  const action = decideKeepalive({
1507
1560
  expired,
1508
1561
  capOk: cap !== null,
1509
1562
  doneSeen: ka.screenDoneUsable && below !== null && hasStandaloneDone(below, ka.site),
1510
- alive: cap !== null && (hasBusySignalFor(cap, kindOf(cfg)) || hasChoiceMenuFor(cap, kindOf(cfg))),
1563
+ alive: cap !== null && (kaBusySignal || kaMenu),
1511
1564
  idle: cap !== null && looksIdleFor(cap, kindOf(cfg)),
1512
1565
  throttleOk: ka.lastHeartbeatAt === null || now - ka.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS,
1513
1566
  });
@@ -1515,10 +1568,12 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1515
1568
  return;
1516
1569
  if (action === 'heartbeat') {
1517
1570
  ka.lastHeartbeatAt = now;
1571
+ const raise = shouldRaiseBusy(kaBusySignal, kaMenu);
1572
+ noteBusyEdge(state, cfg.site, ka.id, raise, logLine);
1518
1573
  // ★継続のログ。出るのは最大でも RELAY_HEARTBEAT_INTERVAL_MS に1回(スロットルに相乗り)。
1519
1574
  logLine(`site=${cfg.site} id=${ka.id} 見張り継続: 職人は生きています→生存通知` +
1520
1575
  `(配達の追跡は解除済み・経過${Math.round((now - ka.startedAt) / 1000)}s)`);
1521
- await heartbeat(cfg, ka.id);
1576
+ await heartbeat(cfg, ka.id, raise);
1522
1577
  return;
1523
1578
  }
1524
1579
  if (action === 'done') {
@@ -1527,6 +1582,8 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1527
1582
  if (await complete(cfg, ka.id, report)) {
1528
1583
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 画面のDONEを検出し完了を報告` +
1529
1584
  `${report != null ? '(報告添付)' : ''}`);
1585
+ // ★終わったら必ず手を下ろす(挙げっぱなしにすると現場が長くふさがる)。
1586
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1530
1587
  state.keepalive = null;
1531
1588
  }
1532
1589
  // 報告に失敗したら畳まない(次tickで再試行。上限が最終的に打ち切る)。
@@ -1538,13 +1595,20 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1538
1595
  // ★上限は無条件で畳む(無限に送り続けない)。中央の言い分はログに残すだけ。
1539
1596
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 上限${Math.round(RELAY_KEEPALIVE_MAX_MS / 60000)}分に到達` +
1540
1597
  `(中央status=${centralStatus ?? '不明'})`);
1598
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1541
1599
  state.keepalive = null;
1542
1600
  return;
1543
1601
  }
1544
1602
  if (isCentralFinished(centralStatus)) {
1545
1603
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 中央で終端(status=${centralStatus})を確認`);
1604
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1546
1605
  state.keepalive = null;
1547
1606
  }
1607
+ // ★'probe' で終端でなかった場合(職人が静止しているだけ)は生存通知を送らない=
1608
+ // 手も挙げない。ここで挙げっぱなしにすると「止まっている職人」で現場をふさぐ。
1609
+ if (action === 'probe') {
1610
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1611
+ }
1548
1612
  // 終端でない(まだ生きている/中央不達)なら畳まない。職人が静止しているだけなので、
1549
1613
  // 生存通知は送らずに見張りだけ続ける=中央は従来どおり stalled へ落とせる(誤魔化さない)。
1550
1614
  }
@@ -1651,6 +1715,15 @@ export async function relayTick(cfg, state) {
1651
1715
  state.restored = true;
1652
1716
  await restoreOrphans(cfg, state);
1653
1717
  }
1718
+ // ── 手を挙げっぱなしを防ぐ最後の砦(★割り込みの根治・便2/3) ──
1719
+ // 「作業中です」と手を挙げた指示が、もう追跡対象(pending でも keepalive でもない)で
1720
+ // なくなっていたら、必ず下ろす。完了・中央での終端・放棄・再起動のどの道で抜けても、
1721
+ // ここを通れば下りる=★挙げっぱなしで現場が長時間ふさがることが構造的に起きない。
1722
+ if (state.busyRaisedId !== null &&
1723
+ state.pending?.id !== state.busyRaisedId &&
1724
+ state.keepalive?.id !== state.busyRaisedId) {
1725
+ noteBusyEdge(state, cfg.site, state.busyRaisedId, false, log);
1726
+ }
1654
1727
  // ── 生存の見張り(keepalive・pending とは別の寿命) ──
1655
1728
  // pending の上限で「配達の追跡」を畳んだ指示について、職人が生きて見える限り中央へ
1656
1729
  // 生存通知を送り続ける(調査 176aebcf・案①-a)。★ここでは return しない=この下の
@@ -1767,7 +1840,10 @@ export async function relayTick(cfg, state) {
1767
1840
  const nowMs = Date.now();
1768
1841
  const throttleOk = state.lastHeartbeatAt === null ||
1769
1842
  nowMs - state.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS;
1770
- const decision = decideHeartbeat(hasBusySignalFor(cap, kindOf(cfg)) || menu, throttleOk);
1843
+ // ★「作業中です」の印は busy シグナルだけで決める(メニュー中は挙げない・shouldRaiseBusy)。
1844
+ // 生存通知の可否は従来どおり busy ∪ メニュー=1文字も変えない。
1845
+ const busySignal = hasBusySignalFor(cap, kindOf(cfg));
1846
+ const decision = decideHeartbeat(busySignal || menu, throttleOk);
1771
1847
  if (decision === 'bare') {
1772
1848
  state.lastHeartbeatAt = nowMs;
1773
1849
  // ★通らなかった理由を1行残す(PR#88 と同じ作法): 撤去によって「黙って止まる」を作らない。
@@ -1780,7 +1856,9 @@ export async function relayTick(cfg, state) {
1780
1856
  '(relayは答えない。PermissionRequestフックが管制へ渡しているか、' +
1781
1857
  'フック未配線なら職人の端末で直接answerが必要)');
1782
1858
  }
1783
- await sendHeartbeat(cfg, state.pending.id);
1859
+ const raise = shouldRaiseBusy(busySignal, menu);
1860
+ noteBusyEdge(state, cfg.site, state.pending.id, raise, log);
1861
+ await sendHeartbeat(cfg, state.pending.id, raise);
1784
1862
  }
1785
1863
  // ── 生存中の自動再送(2-a)。DONE 未達のとき、未着信+アイドルなら同一指示を再送する ──
1786
1864
  // 冪等性最優先: 着信判定(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.7.0",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {