diff --git a/.env.example b/.env.example index 5af72c275..fb26cec86 100644 --- a/.env.example +++ b/.env.example @@ -46,10 +46,10 @@ ALTEROID_DATABASE_URL=postgres://alteroid:@db:5432/ # alteroid credential set TZ --scope app --no-secret # docker compose exec app alteroid credential list # -# 播種される8つ(既定値): `TZ`(Asia/Tokyo)/ `ALTEROID_DAILY_REPORT_AT` +# 播種される9つ(既定値): `TZ`(Asia/Tokyo)/ `ALTEROID_DAILY_REPORT_AT` # (22:00)/ `ALTEROID_INITIATIVE_EVERY`(55)/ `ALTEROID_ACCESS_TOKEN_TTL_DAYS` # (30)/ `ALTEROID_WITHHELD_REPORT_FLUSH_MS`(1800000)/ -# `ALTEROID_INBOX_SETTLE_MS`(2500)/ +# `ALTEROID_INBOX_SETTLE_MS`(2500)/ `ALTEROID_REPORT_SETTLE_MS`(30000)/ # `ALTEROID_MEMORY_TIDY_AT`(03:00)/ `ALTEROID_REPORT_LOOKBACK_DAYS`(3)。 # # ⚠️ **次の5つだけはこの仕組みに乗らない——正本は器の生の環境変数だけである。** @@ -146,7 +146,7 @@ ALTEROID_DATABASE_URL=postgres://alteroid:@db:5432/ # 退避済み生ログ(archive)の古い写しを自動で畳む(tombstone する)周期・分 # (issue #698)。既定は `apps/daemon/src/archive-folder.ts` の # `DEFAULT_ARCHIVE_FOLD_EVERY_MINUTES`。`off` / `none` / `false` / `0` で止まる。 -# ⚠️ **この変数は「播種される7つ」(上の説明)には含まれない** —— DB 正本の +# ⚠️ **この変数は「播種される9つ」(上の説明)には含まれない** —— DB 正本の # 環境変数の袋に播種せず、走行中に `alteroid credential set` で直すことも # できない。直すには器を焼き直す必要がある。 #ALTEROID_ARCHIVE_FOLD_EVERY=60 diff --git a/compose.yaml b/compose.yaml index 17187031f..54214923c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -217,6 +217,13 @@ x-shared-env: &shared-env # (人間の発言・許可確認・質問などは待たない)。0 で無効。**未設定なら既定を使う**—— # 空・空白・範囲外(0〜10000 の外)は既定へ倒れる(`resolveInboxSettleMs` の doc)。 ALTEROID_INBOX_SETTLE_MS: ${ALTEROID_INBOX_SETTLE_MS:-} + # `packages/core/src/clone.ts` の `REPORT_SETTLE_MS`(既定30秒。固定窓)を差し替える。暇なときに + # 最初に届いたマネージャーの報告だけ、先頭の報告が着いてからこの間だけ待って続く報告と1ターンに束ねる + # (人間の発言・許可確認・質問は待たない。待ちの途中に報告以外の合図〔人間・質問・許可確認・失敗の知らせ・ + # 外部の合図・tick・蒸留〕が届けば窓を打ち切る)。外部の合図そのものの待ちは上の + # `ALTEROID_INBOX_SETTLE_MS` のまま。0 で無効。**未設定なら既定を使う**—— + # 空・空白・範囲外(0〜120000 の外)は既定へ倒れる(`resolveReportSettleMs` の doc)。 + ALTEROID_REPORT_SETTLE_MS: ${ALTEROID_REPORT_SETTLE_MS:-} # 退避済み生ログの古い写しを自動で畳む周期(分)。issue #698。**未設定なら # 既定を使う**(`apps/daemon/src/archive-folder.ts` の # `DEFAULT_ARCHIVE_FOLD_EVERY_MINUTES`)——空・空白は分数として読めずに diff --git a/packages/core/src/app-env-var-defaults.ts b/packages/core/src/app-env-var-defaults.ts index 7eb95170f..0480230c6 100644 --- a/packages/core/src/app-env-var-defaults.ts +++ b/packages/core/src/app-env-var-defaults.ts @@ -7,6 +7,7 @@ export const APP_ENV_VAR_DEFAULTS: readonly { name: string; value: string }[] = { name: 'ALTEROID_ACCESS_TOKEN_TTL_DAYS', value: '30' }, { name: 'ALTEROID_WITHHELD_REPORT_FLUSH_MS', value: '1800000' }, { name: 'ALTEROID_INBOX_SETTLE_MS', value: '2500' }, + { name: 'ALTEROID_REPORT_SETTLE_MS', value: '30000' }, { name: 'ALTEROID_MEMORY_TIDY_AT', value: '03:00' }, { name: 'ALTEROID_REPORT_LOOKBACK_DAYS', value: '3' }, ]; diff --git a/packages/core/src/clone-digest-session-delta.test.ts b/packages/core/src/clone-digest-session-delta.test.ts index 2133dca1a..369fc31c0 100644 --- a/packages/core/src/clone-digest-session-delta.test.ts +++ b/packages/core/src/clone-digest-session-delta.test.ts @@ -191,6 +191,7 @@ function boot(script: SdkScript = {}): Boot { ...(script.unavailableDriver === true ? { driver: unavailableDriver } : { queryFn: fn }), env: {}, inboxSettleMs: 0, + reportSettleMs: 0, redeliveryGate: ALWAYS_REDELIVER, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fn, env: {} }), @@ -1047,6 +1048,7 @@ function bootWithHooks(): Boot & { fire: () => Promise } { queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, redeliveryGate: ALWAYS_REDELIVER, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fn, env: {} }), diff --git a/packages/core/src/clone-external-instruction-fold.test.ts b/packages/core/src/clone-external-instruction-fold.test.ts index f770e64d6..93eb99cc9 100644 --- a/packages/core/src/clone-external-instruction-fold.test.ts +++ b/packages/core/src/clone-external-instruction-fold.test.ts @@ -95,6 +95,7 @@ function boot(script: SdkScript = {}): Boot { queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, redeliveryGate: ALWAYS_REDELIVER, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fn, env: {} }), diff --git a/packages/core/src/clone-human-answer-fold.test.ts b/packages/core/src/clone-human-answer-fold.test.ts index f6a04cf69..a80a9060e 100644 --- a/packages/core/src/clone-human-answer-fold.test.ts +++ b/packages/core/src/clone-human-answer-fold.test.ts @@ -90,6 +90,7 @@ function boot(script: SdkScript = {}): Boot { }) as unknown as typeof sdkQuery; const clone = createClone({ + reportSettleMs: 2500, stores: createMemoryStores(), queryFn: fn, env: {}, diff --git a/packages/core/src/clone-inbox-settle.test.ts b/packages/core/src/clone-inbox-settle.test.ts index 7f3adc3d0..ffb39f9e5 100644 --- a/packages/core/src/clone-inbox-settle.test.ts +++ b/packages/core/src/clone-inbox-settle.test.ts @@ -9,8 +9,9 @@ import { captureStderr, humanMessage } from './testing.js'; // 偽の時計で進める(実時間の待ちを足さない: scripts/wallclock-waits-ratchet.test.ts) // `waitFor` は実時間の setTimeout を使うので、ここでは使わず `advance` で時計を進めて確かめる const SETTLE_MS = 2500; -const ON = { inboxSettleMs: SETTLE_MS }; -const OFF = { inboxSettleMs: 0 }; +// 報告の窓も 2.5 秒に固定する: 既定は 30 秒(`clone-report-settle.test.ts`)で、ここは 2.5 秒の窓そのものの振る舞いを測る(期待は変えていない) +const ON = { inboxSettleMs: SETTLE_MS, reportSettleMs: SETTLE_MS }; +const OFF = { inboxSettleMs: 0, reportSettleMs: 0 }; const report = (id: string, text: string): InboxEvent => ({ type: 'manager_message', diff --git a/packages/core/src/clone-ledger-noise.test.ts b/packages/core/src/clone-ledger-noise.test.ts index c80bf3b83..935cb05f4 100644 --- a/packages/core/src/clone-ledger-noise.test.ts +++ b/packages/core/src/clone-ledger-noise.test.ts @@ -80,6 +80,7 @@ function boot(managers: ManagerPool) { queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, managers, redeliveryGate: ALWAYS_REDELIVER, }); diff --git a/packages/core/src/clone-manager-confirmation-and-shutdown.test.ts b/packages/core/src/clone-manager-confirmation-and-shutdown.test.ts index 01a499971..0f2a36f26 100644 --- a/packages/core/src/clone-manager-confirmation-and-shutdown.test.ts +++ b/packages/core/src/clone-manager-confirmation-and-shutdown.test.ts @@ -73,6 +73,7 @@ describe('クローン — マネージャーの確認がいまも待たれて const manager = fakeManagerSdk(); const { fn, calls } = fakeSdk(reply); const clone = createClone({ + reportSettleMs: 2500, redeliveryGate: ALWAYS_REDELIVER, stores: createMemoryStores(), queryFn: fn, @@ -274,6 +275,7 @@ describe('クローン — マネージャーの確認がいまも待たれて }; const clone = createClone({ + reportSettleMs: 2500, redeliveryGate: ALWAYS_REDELIVER, stores: createMemoryStores(), queryFn: fn, diff --git a/packages/core/src/clone-manager-origin-conversation.test.ts b/packages/core/src/clone-manager-origin-conversation.test.ts index 8b5f78989..e40b49aab 100644 --- a/packages/core/src/clone-manager-origin-conversation.test.ts +++ b/packages/core/src/clone-manager-origin-conversation.test.ts @@ -58,6 +58,7 @@ function setupReportTurn(stores: Stores, inTurn: (context: ToolContext) => Promi queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, mcpServerFactory: (context) => { captured = context; return createCloneMcpServer(context); diff --git a/packages/core/src/clone-manager-report-shown.test.ts b/packages/core/src/clone-manager-report-shown.test.ts index 8ef0b2ea4..e8715f984 100644 --- a/packages/core/src/clone-manager-report-shown.test.ts +++ b/packages/core/src/clone-manager-report-shown.test.ts @@ -90,6 +90,7 @@ function boot(script: Script = {}): Boot { queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, redeliveryGate: ALWAYS_REDELIVER, mcpServerFactory: (context) => { captured = context; diff --git a/packages/core/src/clone-memory-update-fold.test.ts b/packages/core/src/clone-memory-update-fold.test.ts index 7b7efe79e..df28b0de8 100644 --- a/packages/core/src/clone-memory-update-fold.test.ts +++ b/packages/core/src/clone-memory-update-fold.test.ts @@ -99,6 +99,7 @@ function boot(script: SdkScript = {}): Boot { const stores = createMemoryStores(); const clone = createClone({ + reportSettleMs: 2500, stores, queryFn: fn, env: {}, diff --git a/packages/core/src/clone-pump-redelivery-hitb-order.test.ts b/packages/core/src/clone-pump-redelivery-hitb-order.test.ts index 71c286ea7..71811c862 100644 --- a/packages/core/src/clone-pump-redelivery-hitb-order.test.ts +++ b/packages/core/src/clone-pump-redelivery-hitb-order.test.ts @@ -44,6 +44,7 @@ describe('Clone#pump — #noteRedeliveryPredicateHitB の位置(characterizati return 'わかった'; }); const clone = createClone({ + reportSettleMs: 2500, redeliveryGate: ALWAYS_REDELIVER, stores, queryFn: fn, diff --git a/packages/core/src/clone-quota-autoclose-5164.test.ts b/packages/core/src/clone-quota-autoclose-5164.test.ts index 1240df128..fca8e2f0e 100644 --- a/packages/core/src/clone-quota-autoclose-5164.test.ts +++ b/packages/core/src/clone-quota-autoclose-5164.test.ts @@ -83,6 +83,7 @@ function boot(managers: ManagerPool, stores: Stores = createMemoryStores()) { queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, managers, redeliveryGate: ALWAYS_REDELIVER, }); diff --git a/packages/core/src/clone-quota-recovered-fold.test.ts b/packages/core/src/clone-quota-recovered-fold.test.ts index 3450d89c3..6e6d8232e 100644 --- a/packages/core/src/clone-quota-recovered-fold.test.ts +++ b/packages/core/src/clone-quota-recovered-fold.test.ts @@ -86,6 +86,7 @@ function boot(managers: ManagerPool) { const { fn, calls } = fakeSdk(); const stores = createMemoryStores(); const clone: CloneHost = createClone({ + reportSettleMs: 2500, stores, queryFn: fn, env: {}, @@ -310,6 +311,7 @@ describe('戻った担当の5時間枠の知らせを、ターンを起こさず }); const stores = createMemoryStores(); const clone = createClone({ + reportSettleMs: 2500, stores, queryFn: fn, env: {}, diff --git a/packages/core/src/clone-quota-recovery-notice.test.ts b/packages/core/src/clone-quota-recovery-notice.test.ts index 87865ca91..44dc95c99 100644 --- a/packages/core/src/clone-quota-recovery-notice.test.ts +++ b/packages/core/src/clone-quota-recovery-notice.test.ts @@ -82,6 +82,7 @@ function stubPool(list: () => Promise): ManagerPool { function boot(managers: ManagerPool, sdkOptions: Parameters[1] = {}) { const { fn, calls } = fakeSdk(undefined, sdkOptions); const clone: CloneHost = createClone({ + reportSettleMs: 2500, stores: createMemoryStores(), queryFn: fn, env: {}, @@ -198,6 +199,7 @@ describe('枠で止まった知らせを配る直前に、担当の回復を添 turnIndex < 2 ? { subtype: 'error_during_execution', text: spendLimitMessage } : undefined, }); const clone = createClone({ + reportSettleMs: 2500, stores: createMemoryStores(), queryFn: fn, env: {}, diff --git a/packages/core/src/clone-recovered-notice-fold.test.ts b/packages/core/src/clone-recovered-notice-fold.test.ts index b00b5c259..3bf3fab74 100644 --- a/packages/core/src/clone-recovered-notice-fold.test.ts +++ b/packages/core/src/clone-recovered-notice-fold.test.ts @@ -78,6 +78,7 @@ function boot(managers: ManagerPool) { const { fn, calls } = fakeSdk(); const stores = createMemoryStores(); const clone: CloneHost = createClone({ + reportSettleMs: 2500, stores, queryFn: fn, env: {}, diff --git a/packages/core/src/clone-report-settle.test.ts b/packages/core/src/clone-report-settle.test.ts new file mode 100644 index 000000000..47abc1a0f --- /dev/null +++ b/packages/core/src/clone-report-settle.test.ts @@ -0,0 +1,303 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { REPORT_SETTLE_MS_ENV_KEY, resolveReportSettleMs } from './clone.js'; +import { setup } from './clone-test-harness.js'; +import { Inbox } from './inbox.js'; +import type { Setup } from './clone-test-harness.js'; +import type { InboxEvent } from './schema.js'; +import { captureStderr, humanMessage } from './testing.js'; + +// 偽の時計で進める(実時間の待ちを足さない: scripts/wallclock-waits-ratchet.test.ts) +const REPORT_MS = 30_000; +const EXTERNAL_MS = 2500; +const ON = { inboxSettleMs: EXTERNAL_MS, reportSettleMs: REPORT_MS }; + +const report = (id: string, text: string): InboxEvent => ({ + type: 'manager_message', + id, + at: new Date().toISOString(), + managerId: 'mgr-report-settle', + kind: 'report', + text, +}); + +const managerKind = (id: string, kind: 'question' | 'permission'): InboxEvent => ({ + type: 'manager_message', + id, + at: new Date().toISOString(), + managerId: 'mgr-report-settle', + kind, + text: `${kind}の本文`, + ...(kind === 'permission' ? { requestId: `req-${id}` } : {}), +}); + +const synthesizedReport = (id: string, text: string): InboxEvent => ({ + type: 'manager_message', + id, + at: new Date().toISOString(), + managerId: 'mgr-report-settle', + kind: 'report', + text, + synthesized: true, +}); + +const external = (id: string, text: string, source = 'ci'): InboxEvent => ({ + type: 'external', + id, + at: new Date().toISOString(), + source, + payload: { text }, +}); + +async function advance(ms: number): Promise { + await vi.advanceTimersByTimeAsync(ms); +} + +// 非同期の連鎖(永続化・記帳)が落ち着くまで、時計を細かく進めて回す +async function settleAsync(): Promise { + for (let i = 0; i < 40; i += 1) await advance(10); +} + +const turnInputs = (s: Setup): string[] => + s.calls.filter((call) => call.kind === 'session').flatMap((call) => call.inputs); + +describe('クローン — 暇なときの報告の束ね待ち(固定窓 30 秒)', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('30 秒の窓に連続して届いた報告は、1ターンに束ねられる', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(report('r1', '報告1本目')); + await advance(10_000); + s.clone.post(report('r2', '報告2本目')); + await advance(10_000); + s.clone.post(report('r3', '報告3本目')); + // 窓が閉じるまでターンを投げない + await advance(9000); + expect(turnInputs(s)).toHaveLength(0); + + await advance(2000); + await settleAsync(); + const inputs = turnInputs(s); + expect(inputs).toHaveLength(1); + expect(inputs[0]).toContain('報告1本目'); + expect(inputs[0]).toContain('報告2本目'); + expect(inputs[0]).toContain('報告3本目'); + await s.clone.stop(); + }); + + it('窓の外(30 秒より後)の報告は別ターンになる。窓は後続の報告で延びない', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(report('r1', '報告1本目')); + await advance(20_000); + // 窓の途中の報告は束ねる。ここで窓が延びるなら、次の報告(35 秒時点)も同じ束に入ってしまう + s.clone.post(report('r2', '報告2本目')); + await advance(15_000); + await settleAsync(); + expect(turnInputs(s)).toHaveLength(1); + + s.clone.post(report('r3', '窓の外の報告')); + await advance(REPORT_MS + 500); + await settleAsync(); + const inputs = turnInputs(s); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain('報告1本目'); + expect(inputs[0]).toContain('報告2本目'); + expect(inputs[0]).not.toContain('窓の外の報告'); + expect(inputs[1]).toContain('窓の外の報告'); + await s.clone.stop(); + }); + + it('窓の途中で人間の発言が届くと、窓を打ち切って即ターンになる', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(report('r1', '報告1本目')); + await advance(5000); + expect(turnInputs(s)).toHaveLength(0); + s.clone.post(humanMessage('割り込み')); + // 期限(30 秒)には遠い 500ms 以内に走り出す + await advance(500); + expect(turnInputs(s).length).toBeGreaterThanOrEqual(1); + await s.clone.stop(); + }); + + it('質問(question)と許可確認(permission)の報告種別は待たない', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(managerKind('p1', 'permission')); + await settleAsync(); + expect(turnInputs(s)).toHaveLength(1); + s.clone.post(managerKind('q1', 'question')); + await settleAsync(); + expect(turnInputs(s)).toHaveLength(2); + await s.clone.stop(); + }); + + it('窓の途中で質問が届くと、窓を打ち切って即ターンになる', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(report('r1', '報告1本目')); + await advance(5000); + expect(turnInputs(s)).toHaveLength(0); + s.clone.post(managerKind('q1', 'question')); + await advance(500); + expect(turnInputs(s).length).toBeGreaterThanOrEqual(1); + await s.clone.stop(); + }); + + it('窓の途中で外部の合図(runner-registry の知らせ)が届くと、窓を打ち切って即ターンになる', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(report('r1', '報告1本目')); + await advance(5000); + expect(turnInputs(s)).toHaveLength(0); + s.clone.post(external('e1', 'runner-registryの知らせ', 'runner-registry')); + await advance(500); + expect(turnInputs(s).length).toBeGreaterThanOrEqual(1); + await s.clone.stop(); + }); + + // 機構が合成した担当の失敗・停止の知らせの本物の形(manager.ts の `#emit`): manager_message・kind: 'report'・synthesized: true + it('窓の途中で合成の失敗の知らせ(kind: report・synthesized: true)が届くと、窓を打ち切って即ターンになる', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(report('r1', '報告1本目')); + await advance(5000); + expect(turnInputs(s)).toHaveLength(0); + s.clone.post(synthesizedReport('f1', '担当が落ちた')); + await advance(500); + expect(turnInputs(s).length).toBeGreaterThanOrEqual(1); + await s.clone.stop(); + }); + + it('合成の失敗の知らせが先に届いても 30 秒の窓では待たない(従来の 2.5 秒の窓のまま)', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(synthesizedReport('f1', '担当が落ちた')); + await advance(EXTERNAL_MS - 500); + expect(turnInputs(s)).toHaveLength(0); + await advance(1000); + await settleAsync(); + expect(turnInputs(s)).toHaveLength(1); + await s.clone.stop(); + }); + + it('待ちに入る前に待ち行列へ積まれていた人間の発言・蒸留・合成の知らせでは、待たずに戻る', async () => { + // 報告の窓の述語(`!isPlainReport`)と同じ形 + const notPlainReport = (event: InboxEvent): boolean => + event.type !== 'manager_message' || event.kind !== 'report' || event.synthesized === true; + const cases: [string, InboxEvent][] = [ + ['人間の発言', humanMessage('先に届いた発言')], + ['蒸留', { type: 'distill', id: 'd1', at: new Date().toISOString(), reason: 'shutdown' }], + ['合成の失敗の知らせ', synthesizedReport('f1', '担当が落ちた')], + ]; + for (const [, event] of cases) { + const inbox = new Inbox(); + inbox.push(event); + let settled = false; + void inbox.settle(REPORT_MS, notPlainReport).then(() => { + settled = true; + }); + await advance(1); + expect(settled).toBe(true); + } + // 当たらないものが積まれているだけなら、期限まで待つ + const inbox = new Inbox(); + inbox.push(report('r2', '先に積まれた報告')); + let settled = false; + void inbox.settle(REPORT_MS, notPlainReport).then(() => { + settled = true; + }); + await advance(REPORT_MS - 1); + expect(settled).toBe(false); + await advance(2); + expect(settled).toBe(true); + }); + + it('報告の窓を 0 にすると、従来どおり最初の1件が単独で即ターンになる', async () => { + const s = setup( + undefined, + undefined, + {}, + {}, + { inboxSettleMs: EXTERNAL_MS, reportSettleMs: 0 }, + ); + s.clone.post(report('r1', '報告1本目')); + await settleAsync(); + expect(turnInputs(s)).toHaveLength(1); + expect(turnInputs(s)[0]).toContain('報告1本目'); + await s.clone.stop(); + }); + + it('外部の合図の窓は報告の窓の影響を受けず、2.5 秒のまま', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(external('e1', '外部の1本目')); + await advance(EXTERNAL_MS - 500); + expect(turnInputs(s)).toHaveLength(0); + await advance(1000); + await settleAsync(); + const inputs = turnInputs(s); + expect(inputs).toHaveLength(1); + expect(inputs[0]).toContain('外部の1本目'); + await s.clone.stop(); + }); + + it('窓の途中で停止すると、30 秒を待たずに抜ける(タイマーも残らない)', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(report('r1', '報告1本目')); + await advance(1000); + let stopped = false; + const stopping = s.clone.stop().then(() => { + stopped = true; + }); + await advance(1000); + expect(stopped).toBe(true); + await stopping; + await settleAsync(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('セッションがある状態で窓の途中に停止しても、shutdown の蒸留が窓を打ち切って 30 秒待たせない', async () => { + const s = setup(undefined, undefined, {}, {}, ON); + s.clone.post(humanMessage('セッションを起こす')); + await settleAsync(); + expect(turnInputs(s)).toHaveLength(1); + s.clone.post(report('r1', '報告1本目')); + await advance(1000); + let stopped = false; + const stopping = s.clone.stop().then(() => { + stopped = true; + }); + await advance(2000); + expect(stopped).toBe(true); + await stopping; + }); +}); + +describe('resolveReportSettleMs', () => { + it('未設定・空・空白のみは既定 30000ms(跡を出さない)', async () => { + const lines = await captureStderr(() => { + expect(resolveReportSettleMs({})).toBe(30_000); + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: '' })).toBe(30_000); + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: ' ' })).toBe(30_000); + }); + expect(lines.filter((line) => line.includes('報告の束ね待ちの設定'))).toEqual([]); + }); + + it('正常値と 0(無効)と上限 120000 はそのまま読む。小数は切り捨てる', () => { + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: '60000' })).toBe(60_000); + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: '0' })).toBe(0); + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: '120000' })).toBe(120_000); + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: '1500.9' })).toBe(1500); + }); + + it('数値でない・負数・上限超えは既定へ倒し、跡を残す(値そのものは載せない)', async () => { + const lines = await captureStderr(() => { + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: 'abc' })).toBe(30_000); + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: '-5' })).toBe(30_000); + expect(resolveReportSettleMs({ [REPORT_SETTLE_MS_ENV_KEY]: '999999' })).toBe(30_000); + }); + const noted = lines.filter((line) => line.includes('報告の束ね待ちの設定')); + expect(noted).toHaveLength(3); + expect(noted.join('\n')).not.toContain('999999'); + }); +}); diff --git a/packages/core/src/clone-session-id-record.test.ts b/packages/core/src/clone-session-id-record.test.ts index 446e1cb78..ac3f0c55e 100644 --- a/packages/core/src/clone-session-id-record.test.ts +++ b/packages/core/src/clone-session-id-record.test.ts @@ -98,6 +98,7 @@ function failingSetCloneSessionId( function bootClone(stores: Stores, fake: Fake): { clone: CloneHost } { const clone = createClone({ + reportSettleMs: 2500, stores, queryFn: fake.fn, env: {}, diff --git a/packages/core/src/clone-session-refusal.test.ts b/packages/core/src/clone-session-refusal.test.ts index e2b41f10e..ad2e4d328 100644 --- a/packages/core/src/clone-session-refusal.test.ts +++ b/packages/core/src/clone-session-refusal.test.ts @@ -74,6 +74,7 @@ describe('クローン — 安全分類器に弾かれ続けたセッション', queryFn, env: options.env ?? {}, inboxSettleMs: 0, + reportSettleMs: 0, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fakeSdk().fn, env: {} }), ]), diff --git a/packages/core/src/clone-situation-notice.test.ts b/packages/core/src/clone-situation-notice.test.ts index 8d61f3568..0435eb3eb 100644 --- a/packages/core/src/clone-situation-notice.test.ts +++ b/packages/core/src/clone-situation-notice.test.ts @@ -147,6 +147,7 @@ function bootClone( ): Fake & { clone: CloneHost } { const fake = fakeSdk(); const clone = createClone({ + reportSettleMs: 2500, stores, queryFn: fake.fn, env: {}, @@ -626,6 +627,7 @@ describe('状況の節と manager_list は、メモリの配達待ち行列を }) as unknown as typeof sdkQuery; const clone = createClone({ + reportSettleMs: 2500, stores: createMemoryStores(), queryFn: fn, env: {}, @@ -767,6 +769,7 @@ describe('同じ会話の2ターン目からは、説明文を要点に畳み、 }) as unknown as Query; }) as unknown as typeof sdkQuery; const clone = createClone({ + reportSettleMs: 2500, stores: createMemoryStores(), queryFn: fn, env: {}, @@ -832,6 +835,7 @@ describe('同じ会話の2ターン目からは、説明文を要点に畳み、 }) as unknown as Query; }) as unknown as typeof sdkQuery; const clone = createClone({ + reportSettleMs: 2500, stores: createMemoryStores(), queryFn: fn, env: {}, @@ -905,6 +909,7 @@ describe('同じ会話の2ターン目からは、説明文を要点に畳み、 }) as unknown as Query; }) as unknown as typeof sdkQuery; const clone = createClone({ + reportSettleMs: 2500, stores: createMemoryStores(), queryFn: fn, env: {}, diff --git a/packages/core/src/clone-superseded-notice.test.ts b/packages/core/src/clone-superseded-notice.test.ts index c4e8727b6..12f92a8a0 100644 --- a/packages/core/src/clone-superseded-notice.test.ts +++ b/packages/core/src/clone-superseded-notice.test.ts @@ -113,6 +113,7 @@ function stubPool(managers: ManagerSummary[]): ManagerPool { function bootClone(stores: Stores, managers: ManagerPool): Fake & { clone: CloneHost } { const fake = fakeSdk(); const clone = createClone({ + reportSettleMs: 2500, stores, queryFn: fake.fn, env: {}, diff --git a/packages/core/src/clone-system-paragraph-dedup.test.ts b/packages/core/src/clone-system-paragraph-dedup.test.ts index dcad4e894..14be1f828 100644 --- a/packages/core/src/clone-system-paragraph-dedup.test.ts +++ b/packages/core/src/clone-system-paragraph-dedup.test.ts @@ -94,6 +94,7 @@ function boot(script: Script = {}): Boot { queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, redeliveryGate: ALWAYS_REDELIVER, ...(script.mergedBatchLimit === undefined ? {} : { mergedBatchLimit: script.mergedBatchLimit }), runners: createRunnerRegistry([ diff --git a/packages/core/src/clone-test-harness.ts b/packages/core/src/clone-test-harness.ts index a4f5536da..4289f5a95 100644 --- a/packages/core/src/clone-test-harness.ts +++ b/packages/core/src/clone-test-harness.ts @@ -256,6 +256,7 @@ export function setup( onFirstTurnToolSearch?: (line: string) => Promise; permissionRuleService?: DelegatedPermissionRuleService; inboxSettleMs?: number; + reportSettleMs?: number; } = {}, ): Setup { const { fn, calls } = fakeSdk(reply, sdkOptions); @@ -263,6 +264,8 @@ export function setup( // 暇なときの束ね待ち(2.5秒)は既定で切る: 単発の報告・外部の合図を投げる試験が、実時間で毎回2.5秒止まるため。試すときは cloneOptions で上書きする // env では切らない: クローンの env は子プロセスの env へ流れ、Options の写しに載るため inboxSettleMs: 0, + // 報告の束ね待ち(30秒)も既定で切る: 理由は上と同じ。報告の窓を試す試験は cloneOptions の `reportSettleMs` で上書きする + reportSettleMs: 0, ...cloneOptions, redeliveryGate: ALWAYS_REDELIVER, stores, diff --git a/packages/core/src/clone-tool-fold-fixed.test.ts b/packages/core/src/clone-tool-fold-fixed.test.ts index 71eb2bf01..6981287cb 100644 --- a/packages/core/src/clone-tool-fold-fixed.test.ts +++ b/packages/core/src/clone-tool-fold-fixed.test.ts @@ -88,6 +88,7 @@ function boot(script: Script = {}): Boot { queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, redeliveryGate: ALWAYS_REDELIVER, mcpServerFactory: (context) => { captured = context; diff --git a/packages/core/src/clone.ts b/packages/core/src/clone.ts index f303bc098..d81a6c56a 100644 --- a/packages/core/src/clone.ts +++ b/packages/core/src/clone.ts @@ -644,6 +644,47 @@ export function resolveInboxSettleMs(env: NodeJS.ProcessEnv = process.env): numb return Math.floor(parsed); } +// 報告だけ外部の合図より長く待つ(#5400): 報告は 44 秒間隔で届く連鎖が多く、2.5 秒では束ならない(batchSize=1 が 8 割強)。 +// 固定窓(先頭の報告が着いてから W だけ。後続で延ばさない): 延ばすと、報告が続く限り返事がいつまでも返らないため。 +// 外部の合図の待ちには広げない: 外部は人間が待つ返事に近いものが混ざり、2.5 秒の桁で足りているため +const REPORT_SETTLE_MS = 30_000; + +// 範囲外の上限: 2 分を超える待ちは、担当が「報告→返事待ち」で進む作業の全体を止めるため +const REPORT_SETTLE_MS_MAX = 120_000; + +export const REPORT_SETTLE_MS_ENV_KEY = 'ALTEROID_REPORT_SETTLE_MS'; + +const REPORT_SETTLE_MS_UNREADABLE_WHAT = '暇なときの報告の束ね待ちの設定'; + +// 0 は無効(報告も、届いた時点で即ターンになる。外部の合図の待ちは `resolveInboxSettleMs` が別に持つ) +export function resolveReportSettleMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env[REPORT_SETTLE_MS_ENV_KEY]; + if (raw === undefined) return REPORT_SETTLE_MS; + const trimmed = raw.trim(); + if (trimmed === '') return REPORT_SETTLE_MS; + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + noteUnreadableRecord( + REPORT_SETTLE_MS_UNREADABLE_WHAT, + `${REPORT_SETTLE_MS_ENV_KEY} chars=${String(trimmed.length)}`, + new Error(`数値として読めない。既定の ${String(REPORT_SETTLE_MS)}ms で走る`), + ); + return REPORT_SETTLE_MS; + } + if (parsed < 0 || parsed > REPORT_SETTLE_MS_MAX) { + noteUnreadableRecord( + REPORT_SETTLE_MS_UNREADABLE_WHAT, + `${REPORT_SETTLE_MS_ENV_KEY} chars=${String(trimmed.length)}`, + new Error( + `0〜${String(REPORT_SETTLE_MS_MAX)}ms の外は待ちにならない。既定の ${String(REPORT_SETTLE_MS)}ms で走る`, + ), + ); + return REPORT_SETTLE_MS; + } + return Math.floor(parsed); +} + // 待つのはマネージャーの報告と外部の合図だけ: 人間・許可確認・質問・timer・蒸留・自発・token-pool の「鍵が戻った」は待たせない // runner-registry・attachment-usage の知らせは待つ: 器の入れ替えの知らせの直後に restored の報告が続くと、先頭の知らせが単独ターンになるため // token-pool は待たない: 枠の解除は `post()` の印と `unshift` が担い待ちには依らないが、復帰の合図そのものを遅らせない(直前に1件へ絞られていて束ねる相手も少ない) @@ -653,6 +694,28 @@ function isSettleCandidate(event: InboxEvent): boolean { return false; } +// 待ちの長さは待たされる合図の種別で選ぶ: 報告だけ長い窓、外部の合図は従来の窓 +// 長い窓の対象は「機構が合成していない報告」だけ(`synthesized` な報告は対象外): 担当の失敗・停止の知らせ(世代の 409・停止・quotaStop)は +// manager.ts の `#emit` が `kind: 'report'`・`synthesized: true` で出すので、報告として数えると 30 秒待たされる。対象外の合成の報告は +// 従来どおり待ち候補のまま短い窓(外部と同じ 2.5 秒・人間起点だけで切る)で扱い、振る舞いを変えない +// 報告の長い窓の打ち切りは「合成していない報告以外が届いたら」へ広げる(人間起点だけにしない): 人間以外の割り込み +// (質問・許可確認・合成の失敗の知らせ・外部の合図・自発や定期の tick・shutdown の蒸留)が最大 30 秒待たされ、`stop()` は shutdown の蒸留の完了を待つので同じだけ止まるため。 +// 短い窓(外部)は従来どおり人間起点だけで切る +function isPlainReport(event: InboxEvent): boolean { + return event.type === 'manager_message' && event.kind === 'report' && event.synthesized !== true; +} + +function settleWaitFor( + event: InboxEvent, + reportMs: number, + externalMs: number, +): { ms: number; interruptWhen: (queued: InboxEvent) => boolean } { + if (isPlainReport(event)) { + return { ms: reportMs, interruptWhen: (queued) => !isPlainReport(queued) }; + } + return { ms: externalMs, interruptWhen: isHumanOriginated }; +} + // 失敗のたびに後退する: 毎分1ターンにしないため const FAILED_TURN_RETRY_DELAYS_MS: readonly number[] = [10, 30, 120, 360, 720].map( (minutes) => minutes * 60_000, @@ -803,8 +866,10 @@ export interface CloneOptions { permissionMode?: PermissionModeName; humanPriority?: boolean; mergedBatchLimit?: number; - /** 暇なときに届いた報告・外部の合図を、束ねるために待つ長さ(ms)。0 で無効。省略は環境変数→既定 */ + /** 暇なときに届いた外部の合図を、束ねるために待つ長さ(ms)。0 で無効。省略は環境変数→既定 */ inboxSettleMs?: number; + /** 暇なときに届いた報告を、束ねるために待つ長さ(ms。固定窓)。0 で無効。省略は環境変数→既定(30 秒) */ + reportSettleMs?: number; dailyReportRetryDelaysMs?: readonly number[]; restoreUnreadRetryDelaysMs?: readonly number[]; contextUsageTimeoutMs?: number; @@ -1038,6 +1103,7 @@ class Clone implements CloneHost { readonly #humanPriority: boolean; readonly #mergedBatchLimit: number; readonly #inboxSettleMs: number; + readonly #reportSettleMs: number; readonly #dailyReportRetryDelays: readonly number[]; readonly #restoreUnreadRetryDelays: readonly number[]; readonly #contextUsageTimeoutMs: number; @@ -1288,6 +1354,7 @@ class Clone implements CloneHost { humanPriority, mergedBatchLimit, inboxSettleMs, + reportSettleMs, dailyReportRetryDelaysMs, restoreUnreadRetryDelaysMs, contextUsageTimeoutMs, @@ -1330,6 +1397,7 @@ class Clone implements CloneHost { this.#humanPriority = humanPriority ?? resolveCloneHumanPriority(envSource); this.#mergedBatchLimit = mergedBatchLimit ?? resolveMergedBatchSizeLimit(envSource); this.#inboxSettleMs = inboxSettleMs ?? resolveInboxSettleMs(envSource); + this.#reportSettleMs = reportSettleMs ?? resolveReportSettleMs(envSource); this.#dailyReportRetryDelays = dailyReportRetryDelaysMs ?? DAILY_REPORT_RETRY_DELAYS_MS; this.#restoreUnreadRetryDelays = restoreUnreadRetryDelaysMs ?? RESTORE_UNREAD_RETRY_DELAYS_MS; this.#contextUsageTimeoutMs = contextUsageTimeoutMs ?? CONTEXT_USAGE_READ_TIMEOUT_MS; @@ -2280,13 +2348,10 @@ class Clone implements CloneHost { // 暇なときに最初に届いた報告・外部の合図だけ、短く待ってから束ねる: 待ち行列を素通りして単独で即ターンになると、直後に続く合図が「1件目だけ単独 + 残りの束」で余計に1ターン走るため // 待ちの間、`#inFlight` は立てたまま・順番待ちの時計は束が決まった後で作る(`arrivedAt` は合図の `at`)ので、この待ちは turn_timing の `queue` 段に乗る // 人間起点の合図が届いたら待ちを打ち切る(`Inbox#settle`)。枠が閉じている回は上で短絡済みで、ここへは来ない - if ( - arrivedWhileIdle && - this.#inboxSettleMs > 0 && - !this.#delivery.inbox.closed && - isSettleCandidate(event) - ) { - await this.#delivery.inbox.settle(this.#inboxSettleMs, isHumanOriginated); + // 報告の窓は固定窓で `#reportSettleMs`、外部の合図の窓は `#inboxSettleMs`(`settleWaitFor`)。閉じれば `Inbox#close` が待ちを即座に畳む + if (arrivedWhileIdle && !this.#delivery.inbox.closed && isSettleCandidate(event)) { + const wait = settleWaitFor(event, this.#reportSettleMs, this.#inboxSettleMs); + if (wait.ms > 0) await this.#delivery.inbox.settle(wait.ms, wait.interruptWhen); } // まとめ読みの判定を呼ぶ前に必ずリセットする: `#drainMergeableWithinLimit` は対象外の起点では呼ばれず、戻さないと前の反復で切ったときの断り書きが持ち越されるため。判定の呼び出しより下に置かない: 立った印を拭き取り、`external` の束でだけ断り書きが黙って消えるため diff --git a/packages/core/src/commitment.test.ts b/packages/core/src/commitment.test.ts index a73d6c8de..29e5bef4d 100644 --- a/packages/core/src/commitment.test.ts +++ b/packages/core/src/commitment.test.ts @@ -103,6 +103,7 @@ function setup( queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fakeSdk().fn, env: {} }), ]), diff --git a/packages/core/src/inbox-persistence.test.ts b/packages/core/src/inbox-persistence.test.ts index 82118ed61..ceea23d78 100644 --- a/packages/core/src/inbox-persistence.test.ts +++ b/packages/core/src/inbox-persistence.test.ts @@ -79,6 +79,7 @@ function bootClone( queryFn: fake.fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fakeSdk().fn, env: {} }), ]), @@ -166,6 +167,7 @@ function bootGatedClone(stores: Stores): Fake & { clone: CloneHost; release: () queryFn: fake.fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fakeSdk().fn, env: {} }), ]), @@ -1175,6 +1177,7 @@ describe('redeliveryGate(Issue #783 続き): 配り直すその瞬間の usa queryFn: fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fakeSdk().fn, env: {} }), ]), @@ -1704,6 +1707,7 @@ describe('消した合図は配達されない(issue #1049)', () => { queryFn: fake.fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fakeSdk().fn, env: {} }), ]), @@ -1800,6 +1804,7 @@ describe('枠で保持している合図も落とす(issue #1049。#deferred queryFn: fake.fn, env: {}, inboxSettleMs: 0, + reportSettleMs: 0, runners: createRunnerRegistry([ createLocalRunner({ workspacePath: '/work', queryFn: fakeSdk().fn, env: {} }), ]), diff --git a/packages/core/src/inbox.ts b/packages/core/src/inbox.ts index 5f6175f61..ffe8e2c82 100644 --- a/packages/core/src/inbox.ts +++ b/packages/core/src/inbox.ts @@ -95,6 +95,8 @@ export class Inbox { // 待っている間に届いたものは通常どおり待ち行列へ積まれる(`next()` の待ち手は居ない)。タイマーは戻る前に必ず片付ける。 settle(ms: number, interruptWhen: (event: InboxEvent) => boolean): Promise { if (this.#closed || ms <= 0) return Promise.resolve(); + // 待ちに入る前に積まれていた割り込みも見る: 取り出してから待ちに入るまでに await が挟まり、その間に届いた人間の発言・蒸留は `onPush` を通らず、窓の長さぶん待たされるため + if (this.#queue.some(interruptWhen)) return Promise.resolve(); // 同時に2つは待たない: 取り出しは直列で、2つ目が来たら先の待ちを畳む this.#settleWatcher?.finish(); return new Promise((resolve) => { diff --git a/packages/core/src/stale-redelivery-batch.test.ts b/packages/core/src/stale-redelivery-batch.test.ts index 528ea9ba2..8b4a248ee 100644 --- a/packages/core/src/stale-redelivery-batch.test.ts +++ b/packages/core/src/stale-redelivery-batch.test.ts @@ -61,6 +61,7 @@ function fakeSdk(): Fake { function bootClone(stores: Stores): Fake & { clone: CloneHost } { const fake = fakeSdk(); const clone = createClone({ + reportSettleMs: 2500, stores, queryFn: fake.fn, env: {},