From 88633b0da4fdc03fea8bf46210b442c5a758ed1c Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 8 Jul 2026 21:51:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(console):=20=E9=A7=95=E9=A7=9B=E8=89=99?= =?UTF-8?q?=E3=80=8C=E4=BB=8A=E6=97=A5=E5=AE=8C=E6=88=90=E3=80=8D=E8=B7=A8?= =?UTF-8?q?=20UTC=20=E6=97=A5=E7=95=8C=E4=BF=AE=E6=AD=A3=E2=80=94=E2=80=94?= =?UTF-8?q?07b=20=E6=9D=BF=E5=AF=A6=E6=85=8B=203=20=E5=80=8B=20parser=20?= =?UTF-8?q?=E8=90=BD=E5=B7=AE=EF=BC=880/10=20=E5=81=87=E7=A9=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDD:system-dev/docs/3-specs/arcrun/search-console/tasks.md task 5.6(本次新增,Phase 5 稽核輪延伸) live bug(2026-07-08 21:40 台北實測):sprint-2026-07b.md 台北今晨 05:50 (=UTC 07-07 21:50,cloud-worker 跨 UTC 日界寫入)勾了 CP1/T7/T5-provider, /console/dashboard-data 卻回 today_done:0/today_total:10、done_today_titles:[]。 根因=任務板 parser 與 07b 實際維護慣例的三個落差(lib/console-dashboard-model.ts): 1. COMPLETED_DAY 要求日期緊貼「完成(」——07b 起署名在日期前 (「完成([cloud-worker] 2026-07-07,…)」)→ T7 的完成日抓不到, [x] 無日期=整項蒸發(不冒領原則誤傷)。 2. 完成標記寫在縮排子彈上(CP1/T5-provider 的任務行只有 [x])→ 舊 parser 只掃行首 checkbox 行 → 同樣蒸發。 3. cloud-worker sandbox 時鐘是 UTC、routine 排程台北清晨(06:30=UTC 前一日 22:30)——它戳的日期 D 其實是台北 D+1 凌晨的完成,todayKey 永遠對不上。 修法(全在純函式層,route 零改動): - DONE_STAMP 改抓「完成(…)」括號內容、日期允許前綴;縮排補充行的完成 標記歸到所屬任務(子彈本身仍不是任務)。 - [cloud-worker] 署名戳 D 展開台北候選日 {D, D+1};progress-guard 跑台北 16:30(UTC 同日)不展開——展開只會多冒領;本機/總管台北戳照舊精確, 「昨天完成的明天不冒領」不變。 - 狀態欄放寬:[🔄→只剩1042](07b 實例)認 doing,任務不再蒸發、today_total 不再少算;x 帶註記仍不冒領 done(寬鬆容錯原則不變)。 驗證:新增 5 案例(07b 逐字真實形態)修前紅(today_done:0、done_today_titles []=live 症狀重現)修後綠;model 測試 27/27;全套 vitest 87/88(1 失敗= pre-existing executor「不存在的零件」,stash 於未改樹複驗同樣紅);tsc 錯誤 行數與未改樹相同(12 行,全在 validate/webhooks-named,workers-types 環境 漂移,非本次引入);另用 Gitea live 07a+07b 檔直灌新 parser:today_done:3、 done_today_titles=[T5-provider, CP1, T7]=板上真相。不部署(留 task 5.5)。 Co-Authored-By: Claude Fable 5 --- .../src/lib/console-dashboard-model.ts | 108 ++++++++++++++---- .../tests/console-dashboard-model.test.ts | 102 ++++++++++++++++- .../3-specs/arcrun/search-console/tasks.md | 4 + 3 files changed, 187 insertions(+), 27 deletions(-) diff --git a/cypher-executor/src/lib/console-dashboard-model.ts b/cypher-executor/src/lib/console-dashboard-model.ts index 05ff13b..536414a 100644 --- a/cypher-executor/src/lib/console-dashboard-model.ts +++ b/cypher-executor/src/lib/console-dashboard-model.ts @@ -249,17 +249,63 @@ export function buildWaitingFallback(entries: KbdbEntry[], nowMs: number): Waiti export interface SprintBoardTask { title: string; - /** [x]=done、[🔄]=doing、[ ]=todo、[!]=blocked(板上維護慣例,對照 sprint-2026-07a.md) */ + /** [x]=done、[🔄]=doing、[ ]=todo、[!]=blocked(板上維護慣例,對照 sprint-2026-07a.md)。 + * 🔄/! 後帶註記也認(07b 實例 `[🔄→只剩1042]`);x 帶註記不冒領 done、整行略過。 */ status: 'done' | 'doing' | 'todo' | 'blocked'; - /** 行內所有「完成(YYYY-MM-DD…)」標記的日期(一行可有多次完成,如 T6-T9 分兩天)。 - * [x] 但沒有帶日期的完成標記 → 空陣列=無法歸日,任何一天都不冒領(寧可略過不誤判)。 */ + /** 「完成(…YYYY-MM-DD…)」標記對應的**台北候選日**(一項可有多次完成,如 T6-T9 分兩天)。 + * [x] 但沒有帶日期的完成標記 → 空陣列=無法歸日,任何一天都不冒領(寧可略過不誤判)。 + * ⚠️ 跨 UTC 日界(fix/console-today-write-dayline,2026-07-08 live bug):cloud-worker + * 的 sandbox 時鐘是 UTC、routine 排程在台北清晨(06:30=UTC 前一日 22:30)——它戳的 + * 日期 D 其實是台北 D+1 凌晨的完成。故帶 [cloud-worker] 署名的戳展開成 {D, D+1} 兩個 + * 候選日(不展開=今天凌晨完成的整天數不到=leo 實際撞到的 0/10);本機/總管戳台北日, + * 照舊精確不展開。 */ completed_days: string[]; /** 合併多個 sprint 檔時標出處(如 "sprint-2026-07b.md") */ sprint?: string; } -const BOARD_LINE = /^- \[( |x|X|🔄|!)\]\s*(.*)$/u; -const COMPLETED_DAY = /完成((\d{4}-\d{2}-\d{2})/gu; +/** 狀態欄放寬到「任意非 ] 內容」再由 classifyBoardMark 分類——07b 實例 `[🔄→只剩1042]` + * 用舊的單字元 regex 會讓整項任務蒸發(連 today_total 都少算)。 */ +const BOARD_LINE = /^- \[([^\]]*)\]\s*(.*)$/u; +/** 完成標記的括號內容(到閉括號/行尾)。日期不再要求緊貼「完成(」——07a 慣例日期在前 + * (`完成(2026-07-02,[cloud-worker])`)、07b 起署名在前(`完成([cloud-worker] + * 2026-07-07,…)`),舊 regex 只吃得下前者,T7 的完成日因此抓不到(live bug 根因 1)。 */ +const DONE_STAMP = /完成(([^)\n]*)/gu; +const STAMP_DAY = /\d{4}-\d{2}-\d{2}/u; +/** 時鐘走 UTC 且排程落在台北 00:00-08:00 窗(會跨日界)的雲端寫手署名。progress-guard + * 跑台北 16:30(UTC 08:30,兩邊同日)不在此列——對它展開只會多冒領不會少算。 */ +const UTC_DAYLINE_ACTORS = /\[cloud-worker\]/u; + +/** 台北日 key 的隔天('2026-07-07' → '2026-07-08')。 */ +export function nextDayKey(day: string): string { + const ms = Date.parse(`${day}T00:00:00Z`); + return Number.isNaN(ms) ? day : new Date(ms + 24 * 3600 * 1000).toISOString().slice(0, 10); +} + +/** checkbox 狀態分類。null=看不懂的標記(寬鬆容錯:整行略過,尤其不冒領 done)。 */ +function classifyBoardMark(mark: string): SprintBoardTask['status'] | null { + if (mark === ' ' || mark === '') return 'todo'; + if (mark === 'x' || mark === 'X') return 'done'; + if (mark.startsWith('🔄')) return 'doing'; + if (mark.startsWith('!')) return 'blocked'; + return null; +} + +/** 從一段文字抽完成標記的台北候選日(含 UTC 寫手的 {D, D+1} 展開,見 SprintBoardTask 註解)。 */ +export function extractCompletedDays(text: string): string[] { + const days: string[] = []; + const push = (d: string) => { + if (!days.includes(d)) days.push(d); + }; + for (const m of text.matchAll(DONE_STAMP)) { + const content = m[1]; + const day = content.match(STAMP_DAY)?.[0]; + if (!day) continue; + push(day); + if (UTC_DAYLINE_ACTORS.test(content)) push(nextDayKey(day)); + } + return days; +} /** 截斷/切冒號後可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉。 * 整串都在括號裡(如「(T10 廢 ENCRYPTION_KEY:…」註記行)會剪成空字串—— @@ -292,9 +338,12 @@ export function cleanBoardTitle(raw: string): string { /** * 從 sprint md 撈「## 任務板」段(到下一個 `## ` 為止,`###` 子段照收),解析頂層 - * checkbox 事項。只認**行首**的 `- [x]` 型式——縮排子彈(「 - 做法:」)與無 checkbox - * 的候補區純敘述行都不是任務,natural 濾掉。找不到任務板段落 → null(caller 誠實 - * fallback dash_task,不硬湊)。解析不了的行一律略過,不誤判成完成(寬鬆容錯原則)。 + * checkbox 事項。只有**行首**的 `- [x]` 型式會成為任務——縮排子彈(「 - 做法:」)與 + * 無 checkbox 的候補區純敘述行都不是任務;但縮排子彈上的**完成標記歸到所屬任務** + * (07b 實際維護慣例:CP1/T5-provider 的任務行只有 [x],完成註記整段寫在子彈上—— + * 舊 parser 不掃子彈 → [x] 無日期=整項蒸發,live bug 根因 2)。找不到任務板段落 → + * null(caller 誠實 fallback dash_task,不硬湊)。解析不了的行一律略過,不誤判成完成 + * (寬鬆容錯原則)。 */ export function parseSprintTaskBoard(md: string, sprintFile?: string): SprintBoardTask[] | null { const secIdx = md.search(/^##\s*任務板/mu); @@ -302,22 +351,34 @@ export function parseSprintTaskBoard(md: string, sprintFile?: string): SprintBoa const section = md.slice(secIdx); const lines = section.split('\n'); const tasks: SprintBoardTask[] = []; + let current: SprintBoardTask | null = null; // 縮排子彈的完成標記要歸的任務 for (const line of lines.slice(1)) { if (/^##\s/.test(line) && !/^###/.test(line)) break; // 下一個 H2 段落,任務板結束 + if (/^###\s/.test(line)) { + current = null; // 新子段開始,別把後面的子彈掛到前段的任務 + continue; + } const m = line.match(BOARD_LINE); - if (!m) continue; - const mark = m[1]; - const body = m[2]; - const title = cleanBoardTitle(body); - if (!title) continue; - const completed: string[] = []; - for (const cm of body.matchAll(COMPLETED_DAY)) completed.push(cm[1]); - tasks.push({ - title, - status: mark === 'x' || mark === 'X' ? 'done' : mark === '🔄' ? 'doing' : mark === '!' ? 'blocked' : 'todo', - completed_days: completed, - sprint: sprintFile, - }); + if (m) { + current = null; + const status = classifyBoardMark(m[1]); + const body = m[2]; + const title = cleanBoardTitle(body); + if (status === null || !title) continue; + current = { title, status, completed_days: extractCompletedDays(body), sprint: sprintFile }; + tasks.push(current); + continue; + } + if (/^\s+\S/.test(line)) { + // 縮排補充行:完成標記歸到所屬任務;行本身不是任務 + if (current) { + for (const d of extractCompletedDays(line)) { + if (!current.completed_days.includes(d)) current.completed_days.push(d); + } + } + continue; + } + if (line.trim() !== '') current = null; // 非縮排敘述行(候補區等)=脫離上一個任務 } return tasks.length ? tasks : null; } @@ -332,8 +393,9 @@ export interface SprintRouteModel { } /** - * 任務板 →「今日完成」模型:done 只數「完成標記日期=今天(台北)」的項;歷史完成項 - * (前幾天勾的)不進今日清單也不進計數——不冒領昨天的功。開著的項照板序全列(今日路線)。 + * 任務板 →「今日完成」模型:done 只數「台北候選完成日含今天」的項(candidate 日集合 + * 見 SprintBoardTask.completed_days 註解,UTC 寫手的跨日界已在 parse 側展開);歷史 + * 完成項(前幾天勾的)不進今日清單也不進計數——不冒領昨天的功。開著的項照板序全列。 */ export function buildSprintRouteModel(tasks: SprintBoardTask[], nowMs: number): SprintRouteModel { const todayKey = taipeiDayKey(nowMs); diff --git a/cypher-executor/tests/console-dashboard-model.test.ts b/cypher-executor/tests/console-dashboard-model.test.ts index ba723f0..e5d3198 100644 --- a/cypher-executor/tests/console-dashboard-model.test.ts +++ b/cypher-executor/tests/console-dashboard-model.test.ts @@ -235,12 +235,13 @@ describe('parseSprintTaskBoard — 任務板解析(寬鬆容錯,解析不了 expect(tasks!.every((t) => t.sprint === 'sprint-2026-07x.md')).toBe(true); }); - it('完成日期:抓行內所有「完成(YYYY-MM-DD」;[x] 無日期=空陣列(不冒領)', () => { + it('完成日期:抓行內所有「完成(…YYYY-MM-DD」;[x] 無日期=空陣列(不冒領)', () => { const tasks = parseSprintTaskBoard(BOARD_MD)!; expect(tasks[2].completed_days).toEqual([]); // RECON:勾了但沒帶日期 → 不歸任何一天 - expect(tasks[3].completed_days).toEqual(['2026-07-02']); - expect(tasks[4].completed_days).toEqual(['2026-07-04', '2026-07-05']); // 一行兩次完成 - expect(tasks[1].completed_days).toEqual(['2026-07-04']); // doing 項的部分完成標記也記著(狀態仍 doing) + // [cloud-worker] 署名=UTC 時鐘寫手 → 戳 D 展開台北候選日 {D, D+1}(跨日界修正) + expect(tasks[3].completed_days).toEqual(['2026-07-02', '2026-07-03']); + expect(tasks[4].completed_days).toEqual(['2026-07-04', '2026-07-05']); // 一行兩次完成(無 UTC 署名不展開) + expect(tasks[1].completed_days).toEqual(['2026-07-04']); // doing 項的部分完成標記也記著([總管]=台北戳不展開) }); it('標題:優先取第一個粗體;無粗體取全形冒號前', () => { @@ -287,3 +288,96 @@ describe('buildSprintRouteModel — 「今日完成」只認今天(台北) expect(buildSprintRouteModel(tasks, afterMidnight).today_done).toBe(0); }); }); + +// ── 2026-07-08 live bug 重現(fix/console-today-write-dayline)─────────────────── +// 實況:sprint-2026-07b.md 台北 07-08 05:50(=UTC 07-07 21:50,cloud-worker 跨 UTC +// 日界寫入)勾了 CP1/T7/T5-provider,dashboard 卻回 today_done:0/today_total:10、 +// done_today_titles:[]。三個 parser 落差(樣本=07b 檔逐字真實形態,非編造): +// 1. T7 的完成標記「完成([cloud-worker] 2026-07-07,…)」——07b 起署名在日期前, +// 舊 regex 要求日期緊貼「完成(」→ 抓不到 → [x] 無日期=整項蒸發。 +// 2. CP1/T5-provider 的完成標記寫在縮排子彈(任務行只有 [x])→ 舊 parser 只掃行首 +// checkbox 行 → 同樣蒸發。 +// 3. cloud-worker 的 sandbox 時鐘是 UTC、routine 排程台北清晨——它的日期戳 07-07 +// 其實是台北 07-08 凌晨的完成,todayKey(台北 07-08)永遠對不上。 +const BOARD_07B_MD = `# Sprint 2026-07b + +## 任務板(P1 > P2 > P3,worker 由上往下取) + +### P1 不卡框架、可無人值守獨立跑(明早可直接開工) + +- [🔄→只剩1042] **T5-graph(Graph 查詢 workflow 化)**:把 kbdb-graph-plugin 的 neighbors/traverse 改寫成 **arcrun workflow**。 + - 做法:在 \`Leo/arcrun-rag\` 建 \`workflows/graph-neighbors.yaml\`。 +- [x] **T5-provider(LLM 換源 Gemini recipe)**:精耕/圖萃取要能換 provider。 + - **\`acr run\` 端到端驗過([cloud-worker] 2026-07-07,\`arcrun-rag\` commit \`8b41dca\`)**:真回生成文字。**✅ 部署驗收完成(2026-07-08 總管)**:production 同步查詢回真 Gemini 生成文字。 +- [x] **CP1(content-pipeline 多出口架構驗證)**:另一條產品線。 + - **完成([cloud-worker] 2026-07-07,\`content-pipeline\` commit \`3376322\`)**:T1+T2 皆完成。零回歸。 + - 驗收:**硬約束=197 篇既有原稿跑 publish,blog 產物與擴展前逐檔一致(無回歸)**。 + +### P2 卡框架(等 Arcrun#24/#25 SDD 回來才動) + +- [ ] **T1 Portal 拆分**(Arcrun#24,[→Arcrun]):獨立搜尋面。 + +### P3 本 repo 可做但非明早首選(有依賴或量體大) + +- [x] **T7 golden questions 框架**:模板先行。**完成([cloud-worker] 2026-07-07,\`arcrun-rag\` commit \`3d44f0a\`)**:模板含題目表。 +- [ ] **P1-spike 地端**:miniflare 起 kbdb+cypher。 + +## 等 leo 清單(RAG 線) + +| # | 事項 | 急迫 | +|---|------|------| +| R1 | pilot 客戶挑選 | 🟡 x | +`; + +describe('07b 跨 UTC 日界 live bug(今天勾的 [x] 不得判成非今天)', () => { + it('parser:署名在日期前的完成標記抓得到;縮排子彈上的完成標記歸到所屬任務', () => { + const tasks = parseSprintTaskBoard(BOARD_07B_MD, 'sprint-2026-07b.md')!; + const byTitle = new Map(tasks.map((t) => [t.title, t])); + // 1. T7:標記在任務行上、但署名 [cloud-worker] 在日期前 + expect(byTitle.get('T7 golden questions 框架')?.completed_days).toContain('2026-07-07'); + // 2. CP1 / T5-provider:標記在縮排子彈上 + expect(byTitle.get('CP1(content-pipeline 多出口架構驗證)')?.completed_days).toContain('2026-07-07'); + expect(byTitle.get('T5-provider(LLM 換源 Gemini recipe)')?.completed_days).toContain('2026-07-08'); + // 縮排子彈本身仍不是任務(「做法:」「驗收:」行沒被誤收) + expect(tasks.some((t) => t.title.startsWith('做法') || t.title.startsWith('驗收'))).toBe(false); + }); + + it('parser:狀態字元帶註記([🔄→只剩1042])不再讓任務蒸發,仍是 doing;x 帶註記不冒領 done', () => { + const tasks = parseSprintTaskBoard(BOARD_07B_MD)!; + expect(tasks.find((t) => t.title.startsWith('T5-graph'))?.status).toBe('doing'); + // 不誤判成完成的寬鬆容錯不變:x 後帶註記=看不懂 → 略過,不冒領 done + const weird = parseSprintTaskBoard('## 任務板\n- [x?] 半勾的東西\n- [ ] 正常項')!; + expect(weird.map((t) => t.title)).toEqual(['正常項']); + }); + + it('UTC 昨天 21:50=台北今天 05:50:cloud-worker 的 UTC 日期戳算進台北今天', () => { + const tasks = parseSprintTaskBoard(BOARD_07B_MD)!; + // 台北 2026-07-08 21:40(=UTC 13:40,live 實測時刻):三個今天勾的 [x] 都要進今日完成 + const m = buildSprintRouteModel(tasks, Date.parse('2026-07-08T13:40:00Z')); + expect(m.done_today_titles).toEqual( + expect.arrayContaining([ + 'T5-provider(LLM 換源 Gemini recipe)', + 'CP1(content-pipeline 多出口架構驗證)', + 'T7 golden questions 框架', + ]), + ); + expect(m.today_done).toBe(3); + expect(m.today_total).toBe(3 + 3); // 3 完成 + 開項(T5-graph doing、T1 todo、P1-spike todo) + // 台北 07-08 05:55(=UTC 07-07 21:55,剛寫完當下)看也一樣算今天 + expect(buildSprintRouteModel(tasks, Date.parse('2026-07-07T21:55:00Z')).today_done).toBe(3); + }); + + it('不冒領邊界:台北 07-09 看,07-07/07-08 的完成全部不再是今日', () => { + const tasks = parseSprintTaskBoard(BOARD_07B_MD)!; + const m = buildSprintRouteModel(tasks, Date.parse('2026-07-09T04:00:00Z')); // 台北 07-09 12:00 + expect(m.today_done).toBe(0); + expect(m.done_today_titles).toEqual([]); + }); + + it('本機/總管的台北日期戳不受 UTC 展開影響(昨天完成的明天不冒領)', () => { + const md = '## 任務板\n- [x] **T-local**:本機做的。**完成(2026-07-07,[總管]派本機 subagent)**:全綠。'; + const tasks = parseSprintTaskBoard(md)!; + expect(tasks[0].completed_days).toEqual(['2026-07-07']); // 不展開 +1 日 + expect(buildSprintRouteModel(tasks, Date.parse('2026-07-08T13:40:00Z')).today_done).toBe(0); + }); +}); diff --git a/system-dev/docs/3-specs/arcrun/search-console/tasks.md b/system-dev/docs/3-specs/arcrun/search-console/tasks.md index a084089..e39863d 100644 --- a/system-dev/docs/3-specs/arcrun/search-console/tasks.md +++ b/system-dev/docs/3-specs/arcrun/search-console/tasks.md @@ -28,6 +28,10 @@ - [x] 5.3 設定頁誠實文案升級(vectorize 開關真相+live 狀態;MCP token TTL 佔位=Arcrun#19 前置) - [x] 5.4 駕駛艙「今日完成/今日路線」接 sprint 任務板(同 #36 等leo清單模式、共用 90s 快取;dash_task 降 fallback;parser 以 live 07a/07b 檔實測) - [ ] 5.5 部署 leo21c+端到端 curl 驗證(不歸稽核輪——紅線不部署,留總管;證據標準同 3.4) +- [x] 5.6 「今日完成」跨 UTC 日界修正(2026-07-08 live bug:today_done 0/10——任務板 parser 三落差: + ① 完成標記日期不再要求緊貼「完成(」(07b 起署名在日期前)② 縮排子彈上的完成標記歸到所屬任務 + ③ [cloud-worker] UTC 時鐘戳 D 展開台北候選日 {D, D+1};另 [🔄→註記] 狀態帶註記不再讓任務蒸發、 + x 帶註記仍不冒領 done。fix/console-today-write-dayline;部署驗證同樣留 5.5) ## Phase 4 — v1(未排)