diff --git a/.project-docs/30-worklog/tasks/20260915-latest-roster-target-01a0858b.md b/.project-docs/30-worklog/tasks/20260915-latest-roster-target-01a0858b.md new file mode 100644 index 0000000..898b529 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260915-latest-roster-target-01a0858b.md @@ -0,0 +1,26 @@ +# Latest pending roster task attachment association + +## Scope and gate +- Feature task on `main`, sole worktree `/Users/andy/IdeaProjects/LWLT-AIBOT`, base `ede1d47c4710b4d8d6976bdb37baaf5c316ccd07`. Main thread only. +- Existing untracked `.idea/` and `dist/ltjt-order-assistant-0.5.184.zip` are unrelated and preserved. No overlapping tracked changes or other worktrees. Overlap: Clear. +- User authorizes selecting the newest waiting roster task when an attachment arrives without a task ID; historical waiting tasks are expected during debugging. Users continue sending business text and a workbook. +- Own attachment target selection in `control-plane/src/task-service.ts`, focused tests, AgentBus reply contract and this task record. Canonical project memory is read-only. +- No real attachment downloads, database access, task submission, ERP writes, deployment, restart, external messages or historical task cleanup. + +## Evidence and plan +- Supplied production logs show attachment download and size/hash validation succeeding, followed by `task_selection_required`. The existing selection query orders pending tasks by last update and rejects more than one candidate. +- Select the newest created eligible roster task, with a deterministic ID tie-break, within existing organization, owner, channel and conversation boundaries. Explicit task selection retains precedence. +- Preserve transactional state/access rechecks and idempotency protections. Do not fall back to an older task after a selected task changes state during validation. +- Test target selection, explicit selection, missing candidates, transaction state changes and duplicate delivery; run required repository, type, control-plane, legacy and build checks. + +## Results +- Attachment-only intake now selects one unarchived `awaiting_attachment` roster task by `created_at DESC, id DESC`, using the existing organization, owner, channel and conversation filters. Both independent and shared-child roster routes are eligible. The multiple-pending-task rejection is removed only from this attachment path. +- Explicit task IDs retain precedence, including selection of an older task. Text and attachment in the same message still use that message's created task. Historical waiting tasks are not mutated or archived by target selection. +- Existing target-row locking, state/access checks and message idempotency remain intact. A selected task that changes state during validation fails without selecting another task. Replaying a message already linked to another task still rejects the conflicting association without writing an attachment or updating that task. +- Added four regression tests for SQL selection/scope contracts, explicit selection and mismatches, missing targets, and the real attachment transaction with mocked storage/normalization. Transaction checks cover successful routing, state races and cross-task replay rejection; no live database or ERP was used. +- Focused intake/target tests 10/10; full control-plane 245/245 and legacy 402/402 (647 total); type check and build PASS. Repository checks 10/10 in a 2702-file byte-verified isolated copy excluding secrets and pre-existing unrelated residue. Legacy HTTP tests used approved local loopback access. Existing `.idea/`, ignored residue and release ZIP are preserved. +- Main-thread final read-only review PASS. No plugin changes, migration, task replay, ERP write, deployment, restart, commit or push. Service deployment/restart is required before this server-side selection rule takes effect. +- [Verification evidence](../../../archive/evidence/2026-09-15/latest-roster-target-01a0858b/README.md). + +## Promotion candidates +- After integration, document attachment-only selection by newest task creation time in canonical roster intake guidance. User has approved this product rule; no user-visible task ID is required for normal use. diff --git a/.project-docs/30-worklog/tasks/20260915-resolution-navigation-01a0858b.md b/.project-docs/30-worklog/tasks/20260915-resolution-navigation-01a0858b.md new file mode 100644 index 0000000..4702ce1 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260915-resolution-navigation-01a0858b.md @@ -0,0 +1,28 @@ +# Read-only resolution navigation recovery + +## Scope and gate +- Feature task; user authorizes fixing roster import `Frame with ID 0 was removed.`. Main thread only, no delegated agents. +- Branch `main`; sole worktree `/Users/andy/IdeaProjects/LWLT-AIBOT`; base `ede1d47c4710b4d8d6976bdb37baaf5c316ccd07`; overlap Clear. +- Preserve prior completed latest-roster-target task edits (task-service, reply contract, tests, task/evidence), unrelated `.idea/`, and the known current 0.5.184 release bytes. No concurrent worktree exists. +- Own extension background resolution/error handling, focused regression tests, version synchronization, mapping/release documentation and release packaging. Canonical project memory remains read-only. +- No real workbook import, ERP write, task replay, deployment, restart, external sending, commit or push. + +## Evidence and implementation plan +- User screenshots show injection succeeded (334 ms), the following resolution action failed (1039 ms), and preflight was blocked at 0 ms. The catch-all labels resolution exceptions as preflight failures. +- Workbook validation already succeeded for 20 rows. Supplementing an attachment does not directly reload ERP. A later keepalive recovery screenshot cannot establish the cause of this earlier navigation. +- Retry only the observed top-frame removal error during read-only target resolution, at most once, while the same execution is still running before writing. Wait for two complete samples of the same approved top document within a bounded window, then inject fresh scripts and re-run unique lookup. Never replay saves or business-blocked lookups. +- Preserve actual failure stage and safe action/step diagnostics, with a per-task snapshot of automatic recovery timing. Keep normal heartbeat logs quiet. +- Add behavioral tests for retry limits, changed documents, execution/cancellation guards, ambiguity, permanent failures and stage reporting. Run required repository, type, control-plane, legacy, build and package/source verification. + +## Results +- Extension 0.5.185 retries only a thrown top-frame removal during read-only resolution, once after two complete samples of the same approved document. The stable-document wait is bounded to 8 seconds and requires a non-empty Chrome documentId; fresh adapter injection and unique lookup still run. The same durable execution must remain running with no prior write boundary; missing/changed execution, cancellation, permissions, closed/unapproved tabs and persistent navigation stop recovery. +- Resolution and route exceptions now keep their actual failure phase. A thrown resolution failure carries explicit read-only evidence and per-task attempt/step/recovery timing, compatible with the existing same-execution late no-write ingestion guard. Normal lookups and business no-match/ambiguity do not retry. Native saves still execute at most once and remain uncertain on frame loss. +- Automatic recovery records the actual reload request timestamp and tab ID; failed queries project only safe status/codes/timestamps and same-tab evidence. Later keepalive updates cannot replace a stored task failure snapshot. Normal heartbeat logging remains quiet. This does not prove what initiated the user's earlier main-page destruction. +- Added nine behavioral regressions (VM harness executing real background functions) covering injection and roundtrip failure, fresh document identity, wait bounds, cancellation/execution changes, permanent failures, actual timing phase, business ambiguity, diagnostic privacy and no save replay. +- Type check, build and control-plane 245/245 passed. Final legacy/repository/package verification is recorded in the linked evidence. Checks use a byte-verified isolated source copy excluding secrets and pre-existing unrelated `.idea/`, `.DS_Store` and logs; original residue is preserved. Legacy HTTP tests use approved local loopback fixtures. +- Main-thread read-only implementation review PASS: no generic scripting retry added, no route/save replay, no relaxed uniqueness or write boundary. Synchronized extension/platform versions, mapping, regression version assertions, current release gate and release manifest; prior 0.5.184 ZIP/manifest archived byte-for-byte. Existing latest-roster-target changes verified unchanged. +- No real ERP execution, import, task replay, deployment, restart, commit or push. Production validation requires loading extension 0.5.185, publishing the platform minimum-version update and refreshing platform/ERP pages. This code recovers transient navigation; continued navigation or other page readiness failures still stop safely. +- [Verification evidence](../../../archive/evidence/2026-09-15/resolution-navigation-01a0858b/README.md). + +## Promotion candidates +- Promote the bounded pre-write resolution retry and actual-stage diagnostics after integration. Keep the cause of the original frame removal unconfirmed until contemporaneous recovery/navigation evidence is available. No canonical promotion in Feature mode. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 153c210..0aa4e4a 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -103,7 +103,7 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const REQUIRED_EXTENSION_VERSION = '0.5.184'; +const REQUIRED_EXTENSION_VERSION = '0.5.185'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; diff --git a/agent设计规范/agentbus-reply-contract.md b/agent设计规范/agentbus-reply-contract.md index a354e9d..6c109f0 100644 --- a/agent设计规范/agentbus-reply-contract.md +++ b/agent设计规范/agentbus-reply-contract.md @@ -30,6 +30,8 @@ 两项名单业务固定走 Program Parser。首次文字指令到达但没有名单文件时,任务进入 `awaiting_attachment`,并返回一条要求发送 `.xls/.xlsx` 的重要消息;该状态不是最终失败,也不会触发 ERP。后续可以在同一 `conversation_id`,或明确携带 `payload.task_id`,发送一个附件而不带正文。 +未指定任务时,附件自动关联同一组织、归属用户、渠道和会话中**最新创建**且未归档的待附件名单任务;按 `created_at` 降序选择,创建时间相同时按内部 ID 降序确定唯一结果。旧任务的重试或错误更新时间不影响选择,历史等待任务保留。用户只需发送业务文字和附件,无需填写任务 ID;桥接器显式传入任务 ID 时仍以指定目标为准,并校验归属。文字指令和附件同一条消息到达时,附件关联该条指令创建的任务。选定目标后若其状态已变化,保留既有事务校验,不转投其他旧任务;同一消息的幂等键不得关联到另一个任务。 + 首次等待附件的入站消息只拥有等待提示,不拥有后续 ERP 最终回执。通过校验并触发解析的附件消息才是该次任务最终回执的归属消息;若历史或重放场景存在多条可归属消息,耐久回执只选择最新一条。因此“文字指令 + 附件”不会在任务完成后各返回一次相同成功信息。 名单入站 `payload.attachments` 最多一个,使用 `name`、`content_type`、`size`、可选 `sha256` 和不含凭据的 HTTPS `url`。当前服务运行在受信内网,控制面允许该 URL 直接使用内网域名、私网 IPv4/IPv6 或 localhost;仍拒绝 HTTP、URL 用户名密码、无法解析的地址、超过大小限制、大小或 SHA-256 不一致以及超过两次的重定向。每次 DNS 解析后固定到选定地址发起 HTTPS 请求,重定向目标重新执行同一 URL 与 DNS 流程。附件 URL 和原始字节不写入任务 JSONB 或日志;允许内网目标意味着 AgentBus 渠道及其上游桥接器必须是受信输入源。 diff --git a/agent设计规范/business-adaptation-registry.md b/agent设计规范/business-adaptation-registry.md index 826bd57..0e7ae43 100644 --- a/agent设计规范/business-adaptation-registry.md +++ b/agent设计规范/business-adaptation-registry.md @@ -32,6 +32,8 @@ ## 列表检索与派发结果边界 +`0.5.185` 起,插件在 ERP 只读目标查询发生主框架移除时,最多等待 8 秒确认当前主文档稳定后重新查询一次;仅同一写入前运行任务可重查,保存及业务候选不唯一不重试。异常保留实际阶段和当时自动恢复诊断,不能仅凭稍后心跳结果推断刷新原因。见[当前任务](../.project-docs/30-worklog/tasks/20260915-resolution-navigation-01a0858b.md)。 + `0.5.184` 起,原生业务列表检索在当前主 iframe 文档上验证页面加载完成、原生搜索函数与可用搜索按钮;旧文档被替换时重新取当前文档,检索中再次切换则停止。失败记录缺失条件与耗时,不能把通用检索失败等同于登录失效。派发回执超时后,只读取插件保存结果;同一任务、执行编号和原连接的明确只读检索未写入阻断可以同步关闭待回查状态,不重新执行。其他真实写入不确定状态保持原核验边界。本项本地回归与上线边界见[当前任务](../.project-docs/30-worklog/tasks/20260915-dispatch-readiness-01a0858b.md)。 ## 新业务登记要求 diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index ae8863b..33cb9e5 100644 --- a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md +++ b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md @@ -2,7 +2,7 @@ ## 当前基线 -- Chrome 插件:`0.5.184` +- Chrome 插件:`0.5.185` - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories` - 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08` - 五个 Skill:`0.5.126`;运营 DOCX:`0.5.133` @@ -119,3 +119,5 @@ - `0.5.183` 修复安排联动检测在定时回调延迟时缺少末次采样的误报:延迟间隔不作为稳定证据,恢复后重新观察,持续变化/延迟及失效表单继续在保存前阻断;增加字段名与采样间隔诊断。用户本次失败的具体触发因素尚未有现场证据;本地回归与发布校验完成后记录于[修复任务](../../../.project-docs/30-worklog/tasks/20260914-arrangement-linkage-stability-01a0858b.md),真实 ERP 验收待部署方执行。 - `0.5.184` 对原生列表搜索按当前 iframe 文档等待完整加载、搜索函数和按钮就绪,并保留缺失条件与耗时诊断;平台将接单等待调整为 10 秒。仅派发超时且同任务、同执行编号、原连接返回明确只读检索未写入证据时,服务端接收迟到阻断结果。不会重新派发任务或放宽真实写入不确定状态。待回查派发任务读取插件已保存结果,历史超时任务可手动读取。需同步更新平台、服务端和插件,无数据库迁移;现场加载失败的具体缺失条件仍待新版诊断确认。见[任务记录](../../../.project-docs/30-worklog/tasks/20260915-dispatch-readiness-01a0858b.md)。 + +- `0.5.185` 只读目标查询主框架移除恢复:有限稳定等待和一次重新注入查询,实际失败阶段及自动恢复快照;不重试保存。新增合成回归覆盖短暂导航、持续切换、执行边界、取消和阶段归属;真实 ERP 验收待部署方完成。见[任务记录](../../../.project-docs/30-worklog/tasks/20260915-resolution-navigation-01a0858b.md)。 diff --git a/archive/evidence/2026-09-15/latest-roster-target-01a0858b/README.md b/archive/evidence/2026-09-15/latest-roster-target-01a0858b/README.md new file mode 100644 index 0000000..38f2d98 --- /dev/null +++ b/archive/evidence/2026-09-15/latest-roster-target-01a0858b/README.md @@ -0,0 +1,24 @@ +# Latest pending roster attachment association verification + +This is immutable verification history for feature task `20260915-latest-roster-target-01a0858b`, not the current business-rule source. Current behavior is defined by `control-plane/src/task-service.ts` and `agent设计规范/agentbus-reply-contract.md`. + +## Change and evidence +- User approved selecting the newest created waiting roster task instead of requiring task-ID selection when historical pending tasks exist. +- Production diagnostic evidence supplied by the user showed successful attachment download and byte validation followed by `task_selection_required`. No signed attachment URL, real file bytes, passenger values or raw production logs are stored here. +- Default target selection is now limited to the newest created unarchived pending roster task within the existing organization/user/channel/conversation scope. `id DESC` breaks equal creation timestamps deterministically. Explicit targets retain precedence. Old task update times do not influence selection. +- Existing transaction state checks and idempotency guards remain in place. No historical task cleanup, live task submission, ERP operation, external message, deployment or restart was performed. + +## Local verification +- `node --test --import tsx control-plane/test/passenger-roster-target.test.ts control-plane/test/passenger-roster-intake.test.ts`: 10 passed. +- `node --run check`: passed. +- `node --run check:repo`: 10 passed in an isolated source copy. +- `node --run test:control-plane`: 245 passed. +- `node --run test:legacy`: 402 passed; local HTTP fixtures required approved 127.0.0.1 listening outside sandbox restrictions. +- `node --run build`: passed. +- `git diff --check`: passed. +- The isolated copy contained 2702 byte-verified source/history/release files at `/private/tmp/ltjt-latest-roster-verify-20260915`. Secrets were excluded; original unrelated `.idea/` and historical residue remained untouched. Dependencies were referenced from the existing local installation. Final task/evidence documentation was synchronized afterward for repository link checks. +- Added tests inspect the executed selection SQL and parameters and exercise the real attachment transaction against mocked storage, authorization and workbook normalization. They do not constitute live PostgreSQL or ERP integration validation. +- Main-thread read-only review: PASS. Only the service selection query, its tests, reply contract and task-local evidence/documentation changed; browser-extension artifacts were untouched. + +## Operational effect +Deploy the updated control plane or restart the local dev service to activate the rule. This record does not claim the production service has been updated or previously failed tasks have been replayed. diff --git a/archive/evidence/2026-09-15/resolution-navigation-01a0858b/README.md b/archive/evidence/2026-09-15/resolution-navigation-01a0858b/README.md new file mode 100644 index 0000000..a832925 --- /dev/null +++ b/archive/evidence/2026-09-15/resolution-navigation-01a0858b/README.md @@ -0,0 +1,9 @@ +# Resolution navigation recovery verification + +Immutable local verification evidence for extension 0.5.185. Synthetic inputs only; no real workbook, customer data, ERP execution, deployment or external messages. + +- Task: [20260915-resolution-navigation-01a0858b](../../../../.project-docs/30-worklog/tasks/20260915-resolution-navigation-01a0858b.md). +- Validation: [checks.txt](checks.txt). +- Tests execute the current background resolution/action/phase functions inside a VM with simulated Chrome navigation, storage and read-only document probes. Native Chrome frame destruction and live ERP acceptance are not claimed. +- Source, current ZIP and prior release archive are byte-verified. Full checks use an isolated copy excluding secrets and pre-existing unrelated IDE/residue files; no unknown original files were cleaned. +- Read-only review: PASS. Retry is limited to the observed removed main-frame error before writes; full saves and ambiguous/no-match business results never retry. Actual failure phase and safe contemporaneous recovery evidence are retained. diff --git a/archive/evidence/2026-09-15/resolution-navigation-01a0858b/checks.txt b/archive/evidence/2026-09-15/resolution-navigation-01a0858b/checks.txt new file mode 100644 index 0000000..e2d833f --- /dev/null +++ b/archive/evidence/2026-09-15/resolution-navigation-01a0858b/checks.txt @@ -0,0 +1,13 @@ +Local verification (2026-09-15) +node --run check: PASS +node --run build: PASS +node --run test:control-plane: 245 passed, 0 failed +node --run test:legacy: 411 passed, 0 failed +node --run check:repo: 10 passed, 0 failed (also included in legacy) +Focused navigation + keepalive tests: 15 passed, 0 failed (included in legacy) +Extension 0.5.185 ZIP: 20 source files, every path and byte verified; SHA-256 defined in dist/release-manifest.json +Prior 0.5.184 ZIP and manifest: byte-preserved in archive/releases/2026-09-15/resolution-navigation-01a0858b +Previous latest-roster-target task source changes: hashes unchanged +Initial full regression detected stale version/count assertions; corrected and rerun successfully. +Original working-copy run encountered known IDE/residue and sandbox loopback constraints; complete validation used the isolated source copy with approved local HTTP fixtures. +No real ERP writes or task replays. Live deployment/Chrome acceptance remains pending. diff --git a/archive/releases/2026-09-15/README.md b/archive/releases/2026-09-15/README.md index 53f0d39..8ad7195 100644 --- a/archive/releases/2026-09-15/README.md +++ b/archive/releases/2026-09-15/README.md @@ -3,3 +3,5 @@ Historical material, not current business rules or release instructions. - [0.5.183 before dispatch/readiness fix](dispatch-readiness-01a0858b/README.md) + +- [0.5.184 before resolution navigation recovery](resolution-navigation-01a0858b/README.md) diff --git a/archive/releases/2026-09-15/resolution-navigation-01a0858b/README.md b/archive/releases/2026-09-15/resolution-navigation-01a0858b/README.md new file mode 100644 index 0000000..788e8a8 --- /dev/null +++ b/archive/releases/2026-09-15/resolution-navigation-01a0858b/README.md @@ -0,0 +1,3 @@ +# Historical release before resolution navigation recovery + +Historical, read-only material; not current business rules or release instructions. Extension 0.5.184 and its original manifest are preserved byte-for-byte. Current deliverables are defined by `dist/release-manifest.json`. diff --git a/archive/releases/2026-09-15/resolution-navigation-01a0858b/ltjt-order-assistant-0.5.184.zip b/archive/releases/2026-09-15/resolution-navigation-01a0858b/ltjt-order-assistant-0.5.184.zip new file mode 100644 index 0000000..0fcb3ab Binary files /dev/null and b/archive/releases/2026-09-15/resolution-navigation-01a0858b/ltjt-order-assistant-0.5.184.zip differ diff --git a/archive/releases/2026-09-15/resolution-navigation-01a0858b/release-manifest.json b/archive/releases/2026-09-15/resolution-navigation-01a0858b/release-manifest.json new file mode 100644 index 0000000..8d888ea --- /dev/null +++ b/archive/releases/2026-09-15/resolution-navigation-01a0858b/release-manifest.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 1, + "generated_on": "2026-09-15", + "baselines": { + "chrome_extension": "0.5.184", + "skills": "0.5.126", + "business_instruction_docx": "0.5.133", + "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" + }, + "artifacts": [ + { + "kind": "chrome_extension", + "version": "0.5.184", + "path": "dist/ltjt-order-assistant-0.5.184.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "f8053808553c6fe1b75161225058447e8fbb13386b36a2048fd3599dfd7d120b" + }, + { + "kind": "skill", + "name": "lwlt-arrangement", + "version": "0.5.126", + "path": "dist/lwlt-arrangement-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-arrangement", + "sha256": "83770befe3a8e4048eacaeacb567c5139bf8a0b3a49f2b1513c26383422f2a9c" + }, + { + "kind": "skill", + "name": "lwlt-confirmation", + "version": "0.5.126", + "path": "dist/lwlt-confirmation-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-confirmation", + "sha256": "543507ed8b68c183d2df6722061b0e927ebb728de4bf254d15f83311c9264018" + }, + { + "kind": "skill", + "name": "lwlt-lifecycle", + "version": "0.5.126", + "path": "dist/lwlt-lifecycle-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-lifecycle", + "sha256": "fff3879d9a4143c34ae51e091b91411e55382ad3f5d77c99d6202a005b1ddbd9" + }, + { + "kind": "skill", + "name": "lwlt-newbooking", + "version": "0.5.126", + "path": "dist/lwlt-newbooking-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-newbooking", + "sha256": "5eb6a7bf5649a0fcee3fbee488a124e1b1d018a642a3a49ebb5439ccf20ccc2f" + }, + { + "kind": "skill", + "name": "lwlt-updating", + "version": "0.5.126", + "path": "dist/lwlt-updating-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-updating", + "sha256": "3bd665207649cbb8e0e5ebb012c916720201fe160eea62bccdf1c3a1f0275d1d" + }, + { + "kind": "business_instruction_docx", + "version": "0.5.133", + "path": "dist/老挝联泰AI指令表-0.5.133.docx", + "source": "agent设计规范/templates/business-input-templates.md", + "builder": "tools/build_business_instruction_docx.py", + "sha256": "879f1acc3a6a60842e59ae8396f72d39504ea1f3a4110a9f305d94553b38848c", + "source_sha256": "37b6b689c5c6a80683dd1deeff52a90ac8b4bd3ac0380e6b771dcb557c0918f7", + "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" + } + ] +} diff --git a/chrome-extension/ltjt-order-assistant/README.md b/chrome-extension/ltjt-order-assistant/README.md index 481410e..8e0a6fe 100644 --- a/chrome-extension/ltjt-order-assistant/README.md +++ b/chrome-extension/ltjt-order-assistant/README.md @@ -11,9 +11,13 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过 ## 当前版本 -当前源码版本为 `0.5.184`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 +当前源码版本为 `0.5.185`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 -0.5.184 当前重点: +0.5.185 当前重点: + +- 目标只读查询遇到 Chrome 主框架移除时,在同一执行仍处于写入前运行状态下,最多等待 8 秒确认同一主文档连续两次加载完成,再重新注入适配器并只读重查一次。持续切换、权限错误、取消或写入边界变化时停止;业务无匹配和多匹配不重试,原生保存不重试。异常按实际查询、路由准备或预检阶段记录;查询失败保留步骤和当时自动恢复时间快照,不记录业务值或恢复正常心跳刷屏。本地合成回归覆盖,真实 ERP 验收待部署方执行。需更新插件及平台最低版本,无数据库迁移。 + +继续保留 0.5.184 的列表及回执修复: - ERP 列表就绪等待跟随当前 iframe 文档,仍要求加载完成、原生搜索函数与可用按钮,切换期间不在旧文档中搜索;失败回执明确区分页面未就绪,并记录不含业务值的条件与耗时。平台接单等待为 10 秒;明确未写入的迟到检索失败可在同执行编号、原连接范围内同步回平台。需同步更新平台/服务端与插件,无数据库迁移;不重新下发任务,真实写入不确定结果继续人工核验。 diff --git a/chrome-extension/ltjt-order-assistant/background.js b/chrome-extension/ltjt-order-assistant/background.js index 367d874..a682aa3 100644 --- a/chrome-extension/ltjt-order-assistant/background.js +++ b/chrome-extension/ltjt-order-assistant/background.js @@ -870,6 +870,8 @@ async function readErpKeepaliveState() { recovery_trigger: String(state.recovery_trigger || ''), recovery_reason: String(state.recovery_reason || ''), recovery_attempt_at: String(state.recovery_attempt_at || ''), + recovery_reload_requested_at: String(state.recovery_reload_requested_at || ''), + recovery_tab_id: Number(state.recovery_tab_id || 0), recovery_completed_at: String(state.recovery_completed_at || ''), recovery_error: String(state.recovery_error || ''), recovery_count: Number(state.recovery_count || 0), @@ -1147,6 +1149,8 @@ async function performErpLinkRecovery(reason, trigger) { recovery_completed_at: '', recovery_error: '', recovery_count: state.recovery_count + 1, + recovery_reload_requested_at: '', + recovery_tab_id: tab.id, last_skip_reason: '', last_tab_url: tab.url || '' }); @@ -1164,7 +1168,11 @@ async function performErpLinkRecovery(reason, trigger) { return { ok: false, status: 'skipped', reason: 'active_erp_execution' }; } if (typeof chrome.tabs?.reload !== 'function') throw new Error('erp_link_refresh_api_unavailable'); - await chrome.tabs.reload(tab.id, { bypassCache: true }); + const reloadRequestedAt = nowIso(); + await Promise.all([ + chrome.tabs.reload(tab.id, { bypassCache: true }), + tryUpdateErpKeepaliveState({ recovery_reload_requested_at: reloadRequestedAt }) + ]); const ready = await waitForTabLoad(tab.id, ERP_LINK_RECOVERY_TIMEOUT_MS); if (ready.status && ready.status !== 'complete') throw new Error('erp_link_refresh_timeout'); if (!isApprovedErpPageUrl(ready.url)) throw erpPageAccessError(ready.url, null); @@ -1635,6 +1643,14 @@ async function canReusePageActionScripts(tabId, action, scriptTarget, taskId = ' } } +function pageActionError(error, actionCode, step) { + const annotated = new Error(error?.message || String(error), { cause: error }); + annotated.code = error?.code; + annotated.page_action = actionCode; + annotated.page_step = step; + return annotated; +} + async function runPageAction(tabId, action, args = [], target = {}, taskId = '') { throwIfTaskCancelled(taskId); const scriptTarget = target.frameIds @@ -1664,7 +1680,7 @@ async function runPageAction(tabId, action, args = [], target = {}, taskId = '') duration_ms: performance.now() - injectionStartedAt, counters: { call_count: 1, failure_count: 1 } }]); - throw error; + throw pageActionError(error, actionCode, 'script_injection'); } const roundtripStartedAt = performance.now(); @@ -1711,7 +1727,7 @@ async function runPageAction(tabId, action, args = [], target = {}, taskId = '') duration_ms: performance.now() - roundtripStartedAt, counters: { call_count: 1, failure_count: 1 } }]); - throw error; + throw pageActionError(error, actionCode, `action_roundtrip.${actionCode}`); } queueOperationTimingDetails(taskId, [{ @@ -2298,8 +2314,124 @@ function withoutResolvedOperation(report = {}) { return publicReport; } +function isRemovedResolutionFrame(error) { + return /^Frame with ID 0 was removed\.?$/i.test(String(error?.message || error)); +} + +async function resolutionRecoverySnapshot(tabId) { + try { + const state = await readErpKeepaliveState(); + const code = (value) => /^[a-z_:\d-]{1,80}$/i.test(String(value || '')) ? String(value) : ''; + const timestamp = (value) => /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(String(value || '')) ? String(value) : ''; + return { + recovery_status: code(state.recovery_status), + recovery_reason: code(state.recovery_reason), + recovery_trigger: code(state.recovery_trigger), + recovery_attempt_at: timestamp(state.recovery_attempt_at), + recovery_reload_requested_at: timestamp(state.recovery_reload_requested_at), + recovery_completed_at: timestamp(state.recovery_completed_at), + same_tab: state.recovery_tab_id ? state.recovery_tab_id === tabId : null + }; + } catch (error) { + return { unavailable: true }; + } +} + +async function assertResolutionRetrySafe(taskId, originalExecution) { + throwIfTaskCancelled(taskId); + const current = await getExecutionRecord(taskId); + throwIfTaskCancelled(taskId); + if (!originalExecution?.execution_id || originalExecution.state !== 'running' + || current?.execution_id !== originalExecution.execution_id || current.state !== 'running' + || current.write_started_at || originalExecution.write_started_at) { + throw new Error('erp_resolution_retry_not_safe'); + } +} + +async function waitForResolutionDocument(tabId, taskId, originalExecution) { + const deadline = Date.now() + 8000; + let previousDocument = ''; + // Read-only probes only. Never use this retry around a save or an entire task. + while (Date.now() < deadline) { + await assertResolutionRetrySafe(taskId, originalExecution); + const tab = await chrome.tabs.get(tabId); + if (!isApprovedErpPageUrl(tab.url) || (tab.pendingUrl && !isApprovedErpPageUrl(tab.pendingUrl))) { + throw new Error('erp_resolution_retry_page_not_allowed'); + } + let documentId = ''; + if (tab.status === 'complete' && !tab.pendingUrl) { + try { + const results = await withDeadline(chrome.scripting.executeScript({ + target: { tabId, frameIds: [0] }, + world: 'MAIN', + func: () => ({ ready: document.readyState === 'complete', url: location.href }) + }), Math.max(1, deadline - Date.now()), 'erp_resolution_document_not_stable'); + const top = results?.find((item) => item.frameId === 0); + if (top?.result?.ready === true && isApprovedErpPageUrl(top.result.url)) { + documentId = String(top.documentId || ''); + } + } catch (error) { + if (!isRemovedResolutionFrame(error)) throw error; + } + } + if (Date.now() < deadline && documentId && documentId === previousDocument) { + await assertResolutionRetrySafe(taskId, originalExecution); + return; + } + previousDocument = documentId; + await delay(250, taskId); + } + throw new Error('erp_resolution_document_not_stable'); +} + +function readonlyResolutionException(error, navigationRetry = null) { + return normalizeLifecycleReport({ + status: 'erp_resolution_blocked', + blockers: [`erp_resolution_navigation_failed:${error?.message || String(error)}`], + preflight: { + stage: 'erp_readonly_resolution', no_erp_write: true, write_attempted: false, + ...(navigationRetry ? { navigation_retry: navigationRetry } : {}) + }, + no_erp_write: true, + write_attempted: false, + manual_review_required: true, + side_effects: ['no_procurement', 'no_payment', 'no_notification', 'no_external_send'] + }); +} + async function resolveOperationInErp(tabId, taskId, operation) { - const results = await runPageAction(tabId, 'resolveLifecycleOperation', [operation], { allFrames: false }, taskId); + const originalExecution = await getExecutionRecord(taskId); + const navigationRetry = { attempts: 0, max_attempts: 2, failures: [] }; + let results; + let failure; + for (let attempt = 0; attempt < 2; attempt += 1) { + navigationRetry.attempts += 1; + try { + results = await runPageAction(tabId, 'resolveLifecycleOperation', [operation], { allFrames: false }, taskId); + break; + } catch (error) { + if (error?.code === 'task_cancelled') throw error; + failure = error; + navigationRetry.failures.push({ + attempt: attempt + 1, + observed_at: nowIso(), + main_frame_removed: isRemovedResolutionFrame(error), + step: error?.page_step || 'resolve_lifecycle_operation', + recovery: await resolutionRecoverySnapshot(tabId) + }); + if (attempt !== 0 || !isRemovedResolutionFrame(error)) break; + try { + await waitForResolutionDocument(tabId, taskId, originalExecution); + } catch (waitError) { + if (waitError?.code === 'task_cancelled') throw waitError; + failure = waitError; + break; + } + } + } + if (!results) { + return { ok: false, report: readonlyResolutionException(failure, navigationRetry), operation: null }; + } const report = normalizeLifecycleReport(results[0]?.result || { status: 'erp_resolution_blocked', blockers: ['erp_resolution_missing_result'], @@ -2307,6 +2439,9 @@ async function resolveOperationInErp(tabId, taskId, operation) { write_attempted: false, manual_review_required: true }); + if (navigationRetry.failures.length) { + report.preflight = { ...report.preflight, navigation_retry: navigationRetry }; + } const resolvedOperation = report.resolved_operation; if (report.status !== 'erp_resolution_ready' || !resolvedOperation || typeof resolvedOperation !== 'object') { return { ok: false, report: withoutResolvedOperation(report), operation: null }; @@ -2334,6 +2469,12 @@ function erpResolutionFailure(operation, report = {}) { && operation.data.departure_dates.length === 1; const targetLabel = identifier ? `单号“${identifier}”` : '当前业务条件'; const blockers = Array.isArray(report.blockers) ? report.blockers.map(String) : []; + if (blockers.some((item) => /Frame with ID 0 was removed|erp_resolution_document_not_stable/.test(item))) { + return { + errorCode: 'erp_resolution_page_changed', + message: 'ERP 主页面在目标查询时失效,查询未能完成;未写入 ERP。请待页面加载稳定后再操作。' + }; + } if (operation?.action === 'shared_child_order_batch_create') { if (blockers.includes('shared_child_batch_no_matching_parent')) { return { @@ -2472,6 +2613,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) { let tab; let preflight; + let preparationStage = 'erp_resolution'; try { tab = await findOrOpenErpTab(taskId); const resolution = await resolveOperationInErp(tab.id, taskId, executionOperation); @@ -2493,6 +2635,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) { return { ok: false, status: 'blocked', task_id: taskId, report: resolution.report }; } executionOperation = resolution.operation; + preparationStage = 'lifecycle_route_preparation'; await setResult(taskId, { stage: 'lifecycle_route_preparation', status: 'running', @@ -2556,9 +2699,30 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) { return { ok: false, status: 'blocked', task_id: taskId, report: strictGate }; } await cacheResolvedExecutionOperation(taskId, executionOperation); + preparationStage = 'lifecycle_preflight'; preflight = await preflightLifecycleAfterRoute(tab.id, executionOperation, taskId, routeToken); } catch (error) { if (error?.code === 'task_cancelled') throw error; + if (preparationStage !== 'lifecycle_preflight') { + const report = preparationStage === 'erp_resolution' + ? readonlyResolutionException(error) + : normalizeLifecycleReport({ + status: 'lifecycle_route_blocked', blockers: [error?.message || String(error)], + no_erp_write: true, write_attempted: false, manual_review_required: true + }); + const failure = preparationStage === 'erp_resolution' + ? erpResolutionFailure(executionOperation, report) + : lifecycleRouteFailure(executionOperation, report); + await chrome.storage.local.set({ + [preparationStage === 'erp_resolution' ? 'lastErpResolution' : 'lastLifecycleRoutePreparation']: report + }); + await publish(taskId, executionOperation, preparationStage, report, { + status: 'blocked', error_code: failure.errorCode, failure_stage: preparationStage, + failure_source: 'plugin_executor', failure_message: failure.message, message: failure.message, + operation_plan: publicPlan, no_erp_write: true, write_attempted: false, blockers: report.blockers + }); + return { ok: false, status: 'blocked', task_id: taskId, report }; + } preflight = normalizeLifecycleReport({ status: 'lifecycle_preflight_blocked', blockers: [error.message || String(error)], diff --git a/chrome-extension/ltjt-order-assistant/inpage.js b/chrome-extension/ltjt-order-assistant/inpage.js index 0b31d7b..e647327 100644 --- a/chrome-extension/ltjt-order-assistant/inpage.js +++ b/chrome-extension/ltjt-order-assistant/inpage.js @@ -6600,7 +6600,7 @@ } window.LTJTOrderAssistant = { - version: '0.5.184', + version: '0.5.185', resolveNativeListSearchValues, lookupKeywordMatchesText, inspectLifecycleSearchCriteria: lifecycleSearchCriteria, diff --git a/chrome-extension/ltjt-order-assistant/manifest.json b/chrome-extension/ltjt-order-assistant/manifest.json index 6cb69ff..616dffe 100644 --- a/chrome-extension/ltjt-order-assistant/manifest.json +++ b/chrome-extension/ltjt-order-assistant/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "联泰下单助手", - "version": "0.5.184", + "version": "0.5.185", "description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。", "permissions": [ "activeTab", diff --git a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js index 43c07a0..412fc9f 100644 --- a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js +++ b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js @@ -944,7 +944,7 @@ window.LTJTOrderAssistant = { ...(window.LTJTOrderAssistant || {}), - version: '0.5.184', + version: '0.5.185', openTeamBatchForm, pingTeamBatchFrame, preflightTeamBatchNative, diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts index ee8cd47..03ccf1e 100644 --- a/control-plane/src/task-service.ts +++ b/control-plane/src/task-service.ts @@ -3652,6 +3652,8 @@ export class TaskService { if (!conversationId) { throw new TaskError('task_selection_required', '发送名单附件时必须指定 task_id 或 conversation_id。', 409); } + // A retry can update an abandoned task; creation time keeps the attachment + // paired with the user's latest roster instruction instead. result = await getPool(this.config).query( `SELECT t.id, t.task_id, t.business_route_id, t.status, t.channel_id, s.conversation_id @@ -3659,11 +3661,13 @@ export class TaskService { JOIN agent_sessions s ON s.task_id = t.id WHERE t.organization_id = $1 AND t.status = 'awaiting_attachment' + AND t.business_route_id IN ('passenger_list_import_independent', 'passenger_list_import_shared_child') AND t.archived_at IS NULL AND s.conversation_id = $2 AND ($3::uuid IS NULL OR t.channel_id = $3::uuid) AND ($4::boolean = false OR t.assigned_user_id = $5) - ORDER BY t.updated_at DESC`, + ORDER BY t.created_at DESC, t.id DESC + LIMIT 1`, [ context.organizationId, conversationId, @@ -3672,14 +3676,6 @@ export class TaskService { context.userId || null ] ); - if ((result.rowCount ?? 0) > 1) { - throw new TaskError( - 'task_selection_required', - '同一会话有多个等待名单附件的任务,请指定 task_id。', - 409, - { task_ids: result.rows.map((row: Record) => text(row.task_id)) } - ); - } if (!result.rowCount) throw new TaskError('awaiting_attachment_task_not_found', '没有找到等待名单附件的任务。', 404); } const row = result.rows[0] as Record; diff --git a/control-plane/test/passenger-roster-target.test.ts b/control-plane/test/passenger-roster-target.test.ts new file mode 100644 index 0000000..c631185 --- /dev/null +++ b/control-plane/test/passenger-roster-target.test.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test, { type TestContext } from 'node:test'; +import { loadConfig } from '../src/config.js'; +import { sha256Bytes, sha256Text } from '../src/crypto.js'; +import { closePool, getPool } from '../src/db.js'; +import { TaskError, TaskService, type TaskContext } from '../src/task-service.js'; + +const context: TaskContext = { + organizationId: 'org-a', userId: 'user-a', role: 'user', + source: 'agentbus', requestId: 'fixture', channelId: 'channel-a' +}; +const selection = { conversationId: 'conversation-a', channelId: 'channel-a' }; +type Selection = Parameters[2]; + +function task(id: string) { + return { + id, task_id: `TASK-${id}`, business_route_id: 'passenger_list_import_shared_child', + status: 'awaiting_attachment', channel_id: 'channel-a', conversation_id: 'conversation-a', + organization_id: 'org-a', assigned_user_id: 'user-a' + }; +} +function result(rows: Array> = []) { + return { rowCount: rows.length, rows }; +} +function setup(t: TestContext) { + const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 29).toString('base64'), + DATABASE_URL: 'postgresql://fixture:fixture@127.0.0.1:1/fixture' }); + const pool = getPool(config); + const service = new TaskService(config); + t.after(async () => { t.mock.restoreAll(); await closePool(); }); + const target = (input: Selection = selection, actor = context) => + (service as unknown as { + passengerRosterAttachmentTarget(c: TaskContext, s: Selection): Promise<{ taskId: string }>; + }).passengerRosterAttachmentTarget(actor, input); + return { pool, service, target }; +} + +test('automatic roster target query uses newest creation time and preserves all scope filters', async t => { + const { pool, target } = setup(t); + t.mock.method(pool, 'query', async (sql: string, params: unknown[]) => { + assert.match(sql, /t\.organization_id = \$1/); + assert.match(sql, /t\.status = 'awaiting_attachment'/); + assert.match(sql, /t\.business_route_id IN \('passenger_list_import_independent', 'passenger_list_import_shared_child'\)/); + assert.match(sql, /t\.archived_at IS NULL/); + assert.match(sql, /s\.conversation_id = \$2/); + assert.match(sql, /\(\$3::uuid IS NULL OR t\.channel_id = \$3::uuid\)/); + assert.match(sql, /\(\$4::boolean = false OR t\.assigned_user_id = \$5\)/); + assert.match(sql, /ORDER BY t\.created_at DESC, t\.id DESC\s+LIMIT 1\s*$/); + assert.doesNotMatch(sql, /updated_at/); + assert.deepEqual(params, ['org-a', 'conversation-a', 'channel-a', true, 'user-a']); + return result([task('newest')]); + }); + for (const role of ['user', 'team_lead'] as const) { + assert.equal((await target(selection, { ...context, role })).taskId, 'TASK-newest'); + } +}); + +test('explicit target still selects an older task and validates channel and conversation', async t => { + const { pool, target } = setup(t); + t.mock.method(pool, 'query', async (sql: string, params: unknown[]) => { + assert.match(sql, /t\.organization_id = \$1 AND t\.task_id = \$2/); + assert.match(sql, /t\.archived_at IS NULL/); + assert.match(sql, /t\.assigned_user_id = \$4/); + assert.doesNotMatch(sql, /ORDER BY|LIMIT/); + assert.deepEqual(params, ['org-a', 'TASK-older', true, 'user-a']); + return result([task('older')]); + }); + const explicit = { ...selection, taskId: 'TASK-older' }; + assert.equal((await target(explicit)).taskId, 'TASK-older'); + await assert.rejects(target({ ...explicit, channelId: 'channel-b' }), { code: 'channel_mismatch' }); + await assert.rejects(target({ ...explicit, conversationId: 'conversation-b' }), { code: 'conversation_mismatch' }); +}); + +test('missing conversation or eligible target never falls back to an unrelated task', async t => { + const { pool, target } = setup(t); + let queries = 0; + t.mock.method(pool, 'query', async () => { queries++; return result(); }); + await assert.rejects(target({ channelId: 'channel-a' }), { code: 'task_selection_required' }); + assert.equal(queries, 0); + await assert.rejects(target(), { code: 'awaiting_attachment_task_not_found' }); + await assert.rejects(target({ ...selection, taskId: 'TASK-missing' }), { code: 'task_not_found' }); +}); + +test('attachment transaction writes only its selected task and keeps race and replay protections', async t => { + const { pool, service } = setup(t); + const attachment = { + fileName: 'synthetic.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + content: Buffer.from('synthetic-normalization-fixture'), source: 'agentbus' as const + }; + const requestHash = sha256Text(`passenger-workbook\0${sha256Bytes(attachment.content)}`); + let chosen = task('newest'); + let locked = { ...chosen }; + let previousKey: Record | undefined; + let attachedTo: unknown[] = []; + let updatedTasks: unknown[] = []; + let statements: string[] = []; + let returnedTaskId = ''; + let targetReads = 0; + t.mock.method(pool, 'query', async (sql: string, params: unknown[]) => { + if (sql.includes('FROM tasks t')) { targetReads++; return result([{ ...chosen }]); } + assert.match(sql, /FROM task_input_attachments/); + assert.equal(params[0], chosen.id); + return result(); + }); + const client = { release() {}, async query(sql: string, params: unknown[] = []) { + statements.push(sql.trim()); + if (sql.includes('SELECT t.*, s.conversation_id')) { + assert.match(sql, /FOR UPDATE OF t, s/); + assert.deepEqual(params, ['org-a', chosen.id, true, 'user-a']); + return result([{ ...locked }]); + } + if (sql.includes('FROM idempotency_keys i')) return result(previousKey ? [previousKey] : []); + if (sql.includes('FROM task_input_attachments')) return result(); + if (sql.includes('INSERT INTO task_input_attachments')) { attachedTo.push(params[1]); return result(); } + if (/UPDATE tasks\s/.test(sql)) { + updatedTasks.push(params[0]); locked.status = 'parse_queued'; return result([{ ...locked }]); + } + if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(sql) || sql.includes('pg_advisory_xact_lock') + || sql.includes('INSERT INTO idempotency_keys')) return result(); + assert.fail(`Unexpected query: ${sql}`); + } }; + t.mock.method(pool, 'connect', async () => client); + // Keep normalization and authorization outside this association regression; + // execute the real attachment transaction, state checks and idempotency logic. + Object.assign(service, { + requireBusinessAuthorization: async () => {}, assertBusinessAuthorizationInTransaction: async () => {}, + normalizePassengerRosterAttachment: async () => ({ canonicalTsv: 'synthetic', rowCount: 1, sourceFormat: 'xlsx' }), + emitEvent: async (_client: unknown, _row: unknown, event: unknown) => event, + audit: async () => {}, notify: () => {}, recordAgentBusAcceptedDelivery: async () => {}, + getTask: async (_org: unknown, id: string) => { returnedTaskId = id; return { ...locked }; } + }); + const first = await service.attachPassengerRosterAttachment(context, attachment, selection); + assert.equal(first.input_attachment?.status, 'normalized'); + assert.equal(returnedTaskId, 'TASK-newest'); + assert.deepEqual(attachedTo, ['newest']); + assert.deepEqual(updatedTasks, ['newest']); + assert.ok(statements.includes('COMMIT')); + + for (const scenario of ['state_changed', 'replay_after_newest_completed'] as const) { + chosen = task(scenario === 'state_changed' ? 'newest' : 'older'); + locked = { ...chosen, status: scenario === 'state_changed' ? 'parse_queued' : 'awaiting_attachment' }; + previousKey = scenario === 'replay_after_newest_completed' + ? { id: 'newest', task_id: 'TASK-newest', request_hash: requestHash } : undefined; + attachedTo = []; updatedTasks = []; statements = []; targetReads = 0; + await assert.rejects(service.attachPassengerRosterAttachment(context, attachment, + { ...selection, idempotencyKey: 'same-agentbus-message' }), (error: unknown) => { + assert.ok(error instanceof TaskError); + assert.equal(error.code, scenario === 'state_changed' ? 'invalid_transition' : 'idempotency_conflict'); + return true; + }); + assert.equal(targetReads, 1, 'never reselect an older task after a transaction failure'); + assert.deepEqual(attachedTo, [], scenario); + assert.deepEqual(updatedTasks, [], scenario); + assert.ok(statements.includes('ROLLBACK'), scenario); + } +}); diff --git a/dist/release-manifest.json b/dist/release-manifest.json index 8d888ea..9a7ae86 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "generated_on": "2026-09-15", "baselines": { - "chrome_extension": "0.5.184", + "chrome_extension": "0.5.185", "skills": "0.5.126", "business_instruction_docx": "0.5.133", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" @@ -10,10 +10,10 @@ "artifacts": [ { "kind": "chrome_extension", - "version": "0.5.184", - "path": "dist/ltjt-order-assistant-0.5.184.zip", + "version": "0.5.185", + "path": "dist/ltjt-order-assistant-0.5.185.zip", "source": "chrome-extension/ltjt-order-assistant", - "sha256": "f8053808553c6fe1b75161225058447e8fbb13386b36a2048fd3599dfd7d120b" + "sha256": "42d12ea2a9e090af3202b683f66a4418fa7aac199686bfb7840251e1fb9b899c" }, { "kind": "skill", diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json index 8461a99..b4c5689 100644 --- a/mappings/lifecycle.mapping.json +++ b/mappings/lifecycle.mapping.json @@ -1,7 +1,7 @@ { "contract_version": "ltjt-lifecycle-v2.9-roster-leader-contact-2026-08", "current_agent_parse_prompt_version": "ltjt-agent-prompt-v1.8-independent-headcount-categories", - "current_extension_version": "0.5.184", + "current_extension_version": "0.5.185", "original_arrangement_date_selection": "Hotel, vehicle, transport and other/filing update selection accepts MM-DD or YYYY-MM-DD; omitted year matches original ERP month/day within the exact group only. Require exactly one persisted row, then freeze its actual full dates and id. Full year is exact; mixed/cross-year endpoints are matched independently with actual range order checked. No match or ambiguity blocks before mutation; show up to five original candidates and ask to resubmit complete dates. Creation and legacy target-date changes retain existing semantics.", "historical_test_marker": "TEST-202609", "scope": "/System/Business/", @@ -29,7 +29,8 @@ "cooldown_minutes": 2, "max_attempts_per_cooldown": 1, "action": "reload the existing allowlisted ERP tab with bypassCache=true, wait for complete, then repeat the read-only GET probe", - "failure_policy": "record structured recovery status and require manual login/inspection; an interrupted MV3 service worker recovery is normalized out of running and the next successful read-only probe returns the recovery state to idle; never retry a business operation" + "failure_policy": "record structured recovery status and require manual login/inspection; an interrupted MV3 service worker recovery is normalized out of running and the next successful read-only probe returns the recovery state to idle; never retry a business operation", + "reload_diagnostics": "record recovery_reload_requested_at and recovery_tab_id when reload is invoked; resolution failures snapshot safe recovery timing so later probes do not replace task evidence; timestamps alone do not prove causation" } }, "erp_account_identity": { @@ -83,6 +84,17 @@ "storage": "runtime state in extension local storage; sanitized public snapshot in the encrypted execution result plus a PII-free JSONB summary" }, "execution_policy": { + "readonly_resolution_navigation_retry": { + "scope": "resolveLifecycleOperation before route preparation and write_started only", + "trigger": "Chrome Frame with ID 0 was removed", + "max_attempts": 2, + "stable_document_wait_ms": 8000, + "readiness": "two complete read-only top document samples at least 250 ms apart, same non-empty Chrome documentId and allowlisted URL; fail closed without documentId", + "execution_guard": "same execution_id, state running, no write_started_at, cancellation checked throughout wait and before retry", + "retry_action": "fresh adapter injection and complete read-only unique target resolution; never replay a save or retry a business no-match/ambiguous result", + "failure_attribution": "resolution exceptions remain erp_resolution; route and preflight exceptions retain their actual phase", + "diagnostics": "bounded per-task attempt count, injection/action step, observation timestamp and safe automatic recovery status/timestamps with same-tab indicator; no business values or URLs; no normal heartbeat log" + }, "release_state": "guarded_narrow_candidate_pending_business_approval", "platform_envelope_requires": [ "task_id", diff --git a/tools/erp-resolution-navigation.test.mjs b/tools/erp-resolution-navigation.test.mjs new file mode 100644 index 0000000..26632f4 --- /dev/null +++ b/tools/erp-resolution-navigation.test.mjs @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import vm from 'node:vm'; + +const source = await readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); +const timing = createRequire(import.meta.url)('../chrome-extension/ltjt-order-assistant/operation-timing.js'); +const slice = (start, end) => { + const first = source.indexOf(start); + const last = source.indexOf(end, first); + assert.ok(first >= 0 && last > first); + return source.slice(first, last); +}; +const removed = () => new Error('Frame with ID 0 was removed.'); +const operation = { action: 'passenger_list_import', data: {} }; +const ready = () => [{ frameId: 0, result: { status: 'erp_resolution_ready', resolved_operation: operation } }]; + +function harness(options = {}) { + let clock = Date.parse('2026-09-15T03:51:14.000Z'); + const state = { + calls: [], probes: 0, injections: 0, lookups: 0, details: [], saved: {}, published: [], + record: { execution_id: 'execution-fixture', state: 'running' }, + tab: { id: 7, status: 'complete', url: 'https://erp.example.invalid/System/Mainlt.asp' }, + recovery: { + recovery_status: 'success', recovery_reason: 'erp_page_access_denied', recovery_trigger: 'session_status', + recovery_attempt_at: '2026-09-15T03:51:13.000Z', recovery_reload_requested_at: '2026-09-15T03:51:13.100Z', + recovery_completed_at: '2026-09-15T03:51:14.000Z', recovery_tab_id: 7, + last_tab_url: 'SECRET_URL', last_error: 'SECRET_ERROR', passenger: 'SECRET_PERSON' + } + }; + const context = { + Error, Promise, setTimeout, clearTimeout, + Date: class extends Date { static now() { return clock; } }, + performance: { now: () => clock }, nowIso: () => new Date(clock).toISOString(), + throwIfTaskCancelled: () => { if (state.cancelled) throw Object.assign(new Error('cancelled'), { code: 'task_cancelled' }); }, + delay: async (ms) => { clock += ms; options.onDelay?.(state); }, + getExecutionRecord: async () => state.record ? { ...state.record } : null, + readErpKeepaliveState: async () => ({ ...state.recovery }), + isApprovedErpPageUrl: (url) => String(url).startsWith('https://erp.example.invalid/'), + canReusePageActionScripts: async () => false, + pageActionFiles: () => ['operation-plans.js', 'inpage.js'], + timingActionCode: (action) => action === 'resolveLifecycleOperation' ? 'resolve_lifecycle_operation' : 'other', + queueOperationTimingDetails: (_task, details) => state.details.push(...details), + queuePageActionResultTiming() {}, + executeErpScript: async (_tab, spec) => { + if (spec.files) { + state.injections += 1; + if (options.failInjection && state.injections === 1) throw removed(); + return [{ frameId: 0 }]; + } + const action = spec.args[0]; + state.calls.push(action); + if (action === 'resolveLifecycleOperation') { + state.lookups += 1; + return options.lookup ? options.lookup(state) : ready(); + } + if (action === 'prepareLifecycleOperation') { + if (options.failRoute) throw removed(); + return [{ frameId: 0, result: { status: 'lifecycle_route_ready' } }]; + } + if (action === 'liveSubmitLifecycleOperation') throw removed(); + throw new Error(`unexpected action ${action}`); + }, + chrome: { + tabs: { get: async () => { if (options.closed) throw new Error('No tab with id: 7.'); return { ...state.tab }; } }, + scripting: { executeScript: async (spec) => { + state.probes += 1; + assert.equal(spec.target.frameIds[0], 0); + assert.equal(spec.files, undefined); + assert.match(spec.func.toString(), /document.readyState/); + if (options.probeError) throw options.probeError(); + const documentId = options.documents ? options.documents(state.probes) : 'document-new'; + return [{ frameId: 0, documentId, result: { ready: true, url: state.tab.url } }]; + } }, + storage: { local: { set: async (patch) => Object.assign(state.saved, patch) } } + }, + operationPlans: { normalizeOperation: (value) => value, publicPlan: (value) => value, validateOperation: () => ({ ok: true }) }, + summaryFromOperation: () => ({}), + findOrOpenErpTab: async () => { if (options.failFind) throw removed(); return state.tab; }, + setResult: async (_task, value) => { + const advanced = timing.advance(state.timingState, { + now_ms: clock, stage: value.stage, status: value.status, details: state.details.splice(0) + }); + state.timingState = advanced.state; + state.timing = advanced.snapshot; + }, + publish: async (_task, _operation, stage, report, extra) => { + state.published.push({ stage, report, ...extra }); + await context.setResult(_task, { stage, ...extra }); + }, + LIFECYCLE_EDIT_DIALOG_ACTIONS: new Set(), + cacheResolvedExecutionOperation: async () => {}, + preflightLifecycleAfterRoute: async () => { + if (options.failPreflight) throw removed(); + return { status: 'lifecycle_preflight_ready', no_erp_write: true, write_attempted: false }; + }, + lifecyclePreflightBusinessFailure: () => null, + transitionCurrentExecution: async (_task, next) => { state.record.state = next; }, + lifecycleResult: { isVerifiedResult: () => false } + }; + vm.createContext(context); + vm.runInContext([ + slice('async function withDeadline', 'function taskRootId'), + slice('const LIFECYCLE_EVIDENCE_DEFAULTS', 'function withStorageMutation'), + slice('function pageActionError', 'function pickResult'), + slice('function withoutResolvedOperation', 'function browserExecutionAction') + ].join('\n'), context); + return { state, resolve: () => context.resolveOperationInErp(7, 'task-fixture', operation), + execute: () => context.executeLifecycleOperation('task-fixture', operation, { ok: true }), + elapsed: () => clock - Date.parse('2026-09-15T03:51:14.000Z') }; +} + +test('normal lookup does not wait; business no-match and ambiguous results never retry', async () => { + for (const count of [null, 0, 2]) { + const h = harness({ lookup: () => count === null ? ready() : [{ result: { + status: 'erp_resolution_blocked', blockers: [`erp_resolution_candidate_count:${count}`] + } }] }); + const result = await h.resolve(); + assert.equal(result.ok, count === null); + assert.equal(h.state.lookups, 1); + assert.equal(h.state.probes, 0); + } +}); + +test('removed query frame waits for a stable current document then reinjects and queries once', async () => { + const h = harness({ lookup: (state) => { if (state.lookups === 1) throw removed(); return ready(); }, + documents: (count) => count === 1 ? 'old' : 'new' }); + const result = await h.resolve(); + assert.equal(result.ok, true); + assert.equal(h.state.injections, 2); + assert.equal(h.state.lookups, 2); + assert.equal(h.state.probes, 3); + assert.equal(h.elapsed(), 500); + const diagnostic = result.report.preflight.navigation_retry; + assert.equal(diagnostic.attempts, 2); + assert.equal(diagnostic.failures[0].step, 'action_roundtrip.resolve_lifecycle_operation'); + assert.equal(diagnostic.failures[0].recovery.same_tab, true); + h.state.recovery.recovery_status = 'running'; + assert.equal(diagnostic.failures[0].recovery.recovery_status, 'success'); + assert.doesNotMatch(JSON.stringify(diagnostic), /SECRET_|erp.example|document-new/); +}); + +test('frame removal during injection also safely recovers and identifies injection as failed step', async () => { + const h = harness({ failInjection: true }); + const result = await h.resolve(); + assert.equal(result.ok, true); + assert.equal(h.state.injections, 2); + assert.equal(h.state.lookups, 1); + assert.equal(result.report.preflight.navigation_retry.failures[0].step, 'script_injection'); +}); + +test('repeated frame removal stops after two lookups with actual resolution failure stage and no-write evidence', async () => { + const h = harness({ lookup: () => { throw removed(); } }); + await h.execute(); + assert.equal(h.state.lookups, 2); + const result = h.state.published.at(-1); + assert.equal(result.stage, 'erp_resolution'); + assert.equal(result.failure_stage, 'erp_resolution'); + assert.equal(result.error_code, 'erp_resolution_page_changed'); + assert.equal(result.report.status, 'erp_resolution_blocked'); + assert.equal(result.report.preflight.stage, 'erp_readonly_resolution'); + assert.equal(result.report.preflight.write_attempted, false); + assert.equal(result.report.no_erp_write, true); + assert.equal(result.report.native_request, null); + assert.equal(result.report.server_response, null); + assert.equal(result.report.requery, null); + assert.equal(result.report.side_effects.length, 4); + assert.equal(h.state.timing.stages.at(-1).stage, 'erp_resolution'); + assert.equal(h.state.timing.stages.at(-1).status, 'blocked'); + assert.equal(h.state.saved.lastLifecyclePreflight, undefined); + assert.equal(h.state.calls.includes('liveSubmitLifecycleOperation'), false); +}); + +test('moving/loading/missing-identity documents cannot pass readiness and the wait is bounded', async () => { + for (const mode of ['moving', 'loading', 'no-document-id', 'frame-removed']) { + const h = harness({ lookup: () => { throw removed(); }, + documents: (count) => mode === 'no-document-id' ? undefined : `new-${count}`, + probeError: mode === 'frame-removed' ? removed : null }); + if (mode === 'loading') h.state.tab.status = 'loading'; + const result = await h.resolve(); + assert.equal(result.ok, false); + assert.equal(h.state.lookups, 1); + assert.equal(h.elapsed(), 8000); + assert.match(result.report.blockers[0], /erp_resolution_document_not_stable/); + } +}); + +test('permanent errors, closed tabs, and disallowed navigation do not rerun queries', async () => { + for (const mode of ['permission', 'closed', 'unapproved', 'pending-unapproved', 'probe-permission']) { + const h = harness({ closed: mode === 'closed', + lookup: () => { throw mode === 'permission' ? new Error('Cannot access contents of the page.') : removed(); }, + probeError: mode === 'probe-permission' ? () => new Error('permission denied') : null }); + if (mode === 'unapproved') h.state.tab.url = 'https://elsewhere.invalid/'; + if (mode === 'pending-unapproved') h.state.tab.pendingUrl = 'https://elsewhere.invalid/'; + assert.equal((await h.resolve()).ok, false); + assert.equal(h.state.lookups, 1); + assert.equal(h.elapsed(), 0); + } +}); + +test('retry requires the same running execution without a prior write boundary; cancellation propagates', async () => { + for (const mode of ['missing', 'write-started', 'old-write', 'replaced', 'write-during-wait', 'cancelled']) { + const h = harness({ lookup: () => { throw removed(); }, onDelay: (state) => { + if (mode === 'replaced') state.record.execution_id = 'other'; + if (mode === 'write-during-wait') state.record.state = 'write_started'; + if (mode === 'cancelled') state.cancelled = true; + } }); + if (mode === 'missing') h.state.record = null; + if (mode === 'write-started') h.state.record.state = 'write_started'; + if (mode === 'old-write') h.state.record.write_started_at = '2026-09-15T03:50:00Z'; + if (mode === 'cancelled') await assert.rejects(h.resolve(), (error) => error.code === 'task_cancelled'); + else assert.match((await h.resolve()).report.blockers[0], /erp_resolution_retry_not_safe/); + assert.equal(h.state.lookups, 1); + } +}); + +test('exceptions before route, during route and during preflight retain the actual failure phase', async () => { + for (const [option, stage] of [['failFind', 'erp_resolution'], ['failRoute', 'lifecycle_route_preparation'], + ['failPreflight', 'lifecycle_preflight']]) { + const h = harness({ [option]: true }); + await h.execute(); + assert.equal(h.state.published.at(-1).stage, stage); + assert.equal(h.state.published.at(-1).no_erp_write, true); + assert.equal(h.state.probes, 0); + assert.equal(h.state.calls.includes('liveSubmitLifecycleOperation'), false); + } +}); + +test('frame removal at live submit is uncertain and never retried', async () => { + const h = harness(); + const result = await h.execute(); + assert.equal(result.status, 'execution_uncertain'); + assert.equal(result.report.no_erp_write, false); + assert.equal(result.report.write_attempted, true); + assert.equal(h.state.calls.filter((action) => action === 'liveSubmitLifecycleOperation').length, 1); + assert.equal(h.state.probes, 0); +}); diff --git a/tools/lifecycle-contract.test.mjs b/tools/lifecycle-contract.test.mjs index 251f5d5..4cd621b 100644 --- a/tools/lifecycle-contract.test.mjs +++ b/tools/lifecycle-contract.test.mjs @@ -1076,7 +1076,7 @@ test('extension reload reconnects the current platform port and missing ERP iden assert.match(background, /ERP 中未找到\$\{targetLabel\},请核对完整单号后重试。/); assert.match(background, /请补充产品名称或领队/); assert.equal((background.match(/const failure = erpResolutionFailure\(executionOperation, resolution\.report\)/g) || []).length, 2); - assert.equal((background.match(/failure_message: failure\.message/g) || []).length, 3); + assert.equal((background.match(/failure_message: failure\.message/g) || []).length, 4); }); test('passenger lifecycle prepares the exact native import route before adapter preflight', async () => { @@ -1374,7 +1374,7 @@ test('passenger explicit server success is terminal without a post-save row requ assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/); assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/); assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.184'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.185'/); }); test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => { @@ -1584,7 +1584,7 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett assert.match(background, /arrangement_resource_candidate_data_missing/); }); -test('extension 0.5.184 keeps reload dormant unless its durable execution state is fully idle', async () => { +test('extension 0.5.185 keeps reload dormant unless its durable execution state is fully idle', async () => { const [background, bridge] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8') @@ -2035,7 +2035,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8')); - assert.equal(mapping.current_extension_version, '0.5.184'); + assert.equal(mapping.current_extension_version, '0.5.185'); assert.equal( mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], 'ertrenshu' @@ -2419,10 +2419,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/); assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/); assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/); - assert.equal(extensionManifest.version, '0.5.184'); - assert.match(inpage, /version: '0\.5\.184'/); - assert.match(teamBatchInpage, /version: '0\.5\.184'/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.184'/); + assert.equal(extensionManifest.version, '0.5.185'); + assert.match(inpage, /version: '0\.5\.185'/); + assert.match(teamBatchInpage, /version: '0\.5\.185'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.185'/); assert.match(inpage, /function strictIsoDate\(value\)/); assert.match(inpage, /const startDate = strictIsoDate\(current\('riqi'\)\)/); assert.match(inpage, /const endDate = strictIsoDate\(current\('riqis'\)\)/);