@whyshu/argent-engine 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANIFEST.json +307 -0
- package/engine/VERSION +1 -0
- package/engine/agents/catalog.json +454 -0
- package/engine/configs/_aliases.proposed.json +651 -0
- package/engine/configs/_migration-plan.proposed.json +899 -0
- package/engine/configs/account.json +272 -0
- package/engine/configs/advanced.json +205 -0
- package/engine/configs/host.json +149 -0
- package/engine/configs/hr.json +27 -0
- package/engine/configs/notify.json +264 -0
- package/engine/core/cli/argent.mjs +277 -0
- package/engine/core/contracts/argent.contracts.json +1065 -0
- package/engine/core/contracts/verbs.schema.json +2539 -0
- package/engine/core/lib/argent-datadir.mjs +223 -0
- package/engine/core/lib/audit.mjs +187 -0
- package/engine/core/lib/claim.mjs +258 -0
- package/engine/core/lib/config-catalog.mjs +1172 -0
- package/engine/core/lib/doctor-customer.mjs +577 -0
- package/engine/core/lib/flow-list.mjs +138 -0
- package/engine/core/lib/flow-registry.mjs +48 -0
- package/engine/core/lib/init.mjs +247 -0
- package/engine/core/lib/ledger.mjs +124 -0
- package/engine/core/lib/license-activate.mjs +813 -0
- package/engine/core/lib/license-device.mjs +314 -0
- package/engine/core/lib/license-file.mjs +349 -0
- package/engine/core/lib/license-guard.mjs +123 -0
- package/engine/core/lib/license-ops.mjs +1645 -0
- package/engine/core/lib/license-verdict.mjs +133 -0
- package/engine/core/lib/license.mjs +577 -0
- package/engine/core/lib/notify/dedup.mjs +101 -0
- package/engine/core/lib/notify/feishu.mjs +38 -0
- package/engine/core/lib/notify/index.mjs +95 -0
- package/engine/core/lib/notify/verb.mjs +327 -0
- package/engine/core/lib/notify/webhook.mjs +135 -0
- package/engine/core/lib/notify/wecom.mjs +36 -0
- package/engine/core/lib/python.mjs +145 -0
- package/engine/core/lib/sop.mjs +116 -0
- package/engine/core/lib/versions.mjs +205 -0
- package/engine/core/mcp/client.mjs +67 -0
- package/engine/core/mcp/http.mjs +138 -0
- package/engine/core/mcp/server.mjs +702 -0
- package/engine/scripts/_engine_rules_probe.py +15 -0
- package/engine/scripts/analyze-full-day.py +568 -0
- package/engine/scripts/archive/feishu-bridge.js +246 -0
- package/engine/scripts/argent-agents.mjs +527 -0
- package/engine/scripts/argent-capabilities.mjs +776 -0
- package/engine/scripts/argent-catalog.mjs +186 -0
- package/engine/scripts/argent-datadir.py +62 -0
- package/engine/scripts/argent-doctor.mjs +624 -0
- package/engine/scripts/argent-env-write.mjs +302 -0
- package/engine/scripts/argent-schedule.mjs +620 -0
- package/engine/scripts/argent-setup-check.mjs +369 -0
- package/engine/scripts/argent-upgrade.mjs +1046 -0
- package/engine/scripts/base-bootstrap.sh +575 -0
- package/engine/scripts/billing-mode-daily.sh +12 -0
- package/engine/scripts/build-engine-package.mjs +123 -0
- package/engine/scripts/capability-packs.lock.json +484 -0
- package/engine/scripts/capability_pack.py +1242 -0
- package/engine/scripts/check-1.0-criteria.mjs +355 -0
- package/engine/scripts/check-agent-catalog.mjs +141 -0
- package/engine/scripts/check-billing-mode.sh +377 -0
- package/engine/scripts/check-child-sessions.mjs +83 -0
- package/engine/scripts/check-config-catalog.mjs +677 -0
- package/engine/scripts/check-docs-drift.mjs +522 -0
- package/engine/scripts/check-dsh-web-expose.sh +98 -0
- package/engine/scripts/check-judgment-drift.mjs +403 -0
- package/engine/scripts/check-license.mjs +199 -0
- package/engine/scripts/check-model-auth.py +443 -0
- package/engine/scripts/check-pack-closure.mjs +661 -0
- package/engine/scripts/check-portable-symbols.mjs +116 -0
- package/engine/scripts/check-preset-landing.mjs +124 -0
- package/engine/scripts/check-production-home.mjs +602 -0
- package/engine/scripts/check-role-mapping.mjs +461 -0
- package/engine/scripts/check-sample-redaction.mjs +320 -0
- package/engine/scripts/check-skill-classification.mjs +188 -0
- package/engine/scripts/check-sop-consistency.mjs +431 -0
- package/engine/scripts/check-test-env-safety.mjs +627 -0
- package/engine/scripts/check-version-consistency.mjs +452 -0
- package/engine/scripts/ci-run.sh +558 -0
- package/engine/scripts/config-explain.mjs +278 -0
- package/engine/scripts/configure-provider.rb +29 -0
- package/engine/scripts/customer-config-migrate.mjs +179 -0
- package/engine/scripts/customer-preflight.py +892 -0
- package/engine/scripts/customer-readiness.sh +134 -0
- package/engine/scripts/daily-report-inbox.py +242 -0
- package/engine/scripts/daily-report-reconcile.mjs +312 -0
- package/engine/scripts/daily-report.mjs +1310 -0
- package/engine/scripts/daily-report.sh +46 -0
- package/engine/scripts/data/lip-product-dict.json +306 -0
- package/engine/scripts/deliver-customer.mjs +932 -0
- package/engine/scripts/delivery-faces.txt +31 -0
- package/engine/scripts/desktop-trial.sh +146 -0
- package/engine/scripts/dev-window.sh +260 -0
- package/engine/scripts/drill-clean-machine.sh +315 -0
- package/engine/scripts/dsh-api-client.mjs +215 -0
- package/engine/scripts/dsh-backup-scope.sh +83 -0
- package/engine/scripts/dsh-offline-pack.mjs +77 -0
- package/engine/scripts/dsh-overlay-pin.sh +87 -0
- package/engine/scripts/dsh-plane.mjs +153 -0
- package/engine/scripts/dsh-portforward.py +223 -0
- package/engine/scripts/dsh-preflight.mjs +179 -0
- package/engine/scripts/dsh-release-watch-daily.sh +19 -0
- package/engine/scripts/dsh-release-watch.mjs +458 -0
- package/engine/scripts/dsh-remote-check.sh +99 -0
- package/engine/scripts/dsh-restart.sh +179 -0
- package/engine/scripts/dsh-restore.sh +111 -0
- package/engine/scripts/dsh-scope-rewire.mjs +803 -0
- package/engine/scripts/dsh-snapshot.mjs +116 -0
- package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
- package/engine/scripts/dsh-upgrade.mjs +612 -0
- package/engine/scripts/dsh-url.sh +86 -0
- package/engine/scripts/dsh-web-launch.mjs +155 -0
- package/engine/scripts/ensure-store-windows.py +509 -0
- package/engine/scripts/entitlements.py +458 -0
- package/engine/scripts/env_probe.py +411 -0
- package/engine/scripts/feishu-card.mjs +252 -0
- package/engine/scripts/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +720 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +86 -0
- package/engine/scripts/flow-timers.sh +182 -0
- package/engine/scripts/flow-trigger-wake.mjs +284 -0
- package/engine/scripts/flow-trigger.sh +571 -0
- package/engine/scripts/flow_billing.py +354 -0
- package/engine/scripts/flow_capability_stats.py +314 -0
- package/engine/scripts/flow_case.py +194 -0
- package/engine/scripts/flow_claim.py +497 -0
- package/engine/scripts/flow_config.py +1024 -0
- package/engine/scripts/flow_failure.py +588 -0
- package/engine/scripts/flow_learn.py +105 -0
- package/engine/scripts/flow_ledger.py +235 -0
- package/engine/scripts/flow_policy.py +238 -0
- package/engine/scripts/flow_preflight.py +339 -0
- package/engine/scripts/flow_registry.py +341 -0
- package/engine/scripts/flow_retry.py +304 -0
- package/engine/scripts/flow_return_to_ops.py +191 -0
- package/engine/scripts/flow_source.py +315 -0
- package/engine/scripts/flow_sources/__init__.py +53 -0
- package/engine/scripts/flow_sources/cases_json.py +106 -0
- package/engine/scripts/flow_sources/common.py +428 -0
- package/engine/scripts/flow_sources/feishu_base.py +273 -0
- package/engine/scripts/flow_stale.py +185 -0
- package/engine/scripts/flow_status_reconcile.py +344 -0
- package/engine/scripts/flow_triage.py +849 -0
- package/engine/scripts/flow_write_verify.py +496 -0
- package/engine/scripts/fulfillment.js +586 -0
- package/engine/scripts/gen-agent-bundles.mjs +276 -0
- package/engine/scripts/gen-preset-rows.mjs +259 -0
- package/engine/scripts/init-ml.sh +2275 -0
- package/engine/scripts/install-internal-role.sh +190 -0
- package/engine/scripts/install-license.mjs +641 -0
- package/engine/scripts/install-one-shot.mjs +665 -0
- package/engine/scripts/integration_contract.py +220 -0
- package/engine/scripts/integrations/wenyao/adapter.py +98 -0
- package/engine/scripts/integrations/xzh/adapter.py +75 -0
- package/engine/scripts/invoice-mapping.example.json +40 -0
- package/engine/scripts/invoice-table.sh +194 -0
- package/engine/scripts/invoice_carrier_notice.py +279 -0
- package/engine/scripts/invoice_fail_closeout.py +297 -0
- package/engine/scripts/invoice_lip_runner.py +1508 -0
- package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
- package/engine/scripts/invoice_ready_audit.py +191 -0
- package/engine/scripts/invoice_runner.py +675 -0
- package/engine/scripts/invoice_stageb.py +516 -0
- package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
- package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
- package/engine/scripts/lease.py +377 -0
- package/engine/scripts/lib/agent-install.mjs +649 -0
- package/engine/scripts/lib/argent-datadir.mjs +16 -0
- package/engine/scripts/lib/argent_datadir.py +262 -0
- package/engine/scripts/lib/argent_filelock.py +507 -0
- package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
- package/engine/scripts/lib/cdp_ports.py +197 -0
- package/engine/scripts/lib/customer-env.mjs +102 -0
- package/engine/scripts/lib/daily-report-core.mjs +517 -0
- package/engine/scripts/lib/dep-declaration.mjs +81 -0
- package/engine/scripts/lib/deps-migrate.mjs +310 -0
- package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
- package/engine/scripts/lib/dsh-scope.mjs +500 -0
- package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
- package/engine/scripts/lib/dsh-versions.mjs +96 -0
- package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
- package/engine/scripts/lib/is-direct-run.mjs +61 -0
- package/engine/scripts/lib/patch-compose.mjs +241 -0
- package/engine/scripts/lib/patch-target.mjs +35 -0
- package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
- package/engine/scripts/lib/plugin-packages.mjs +332 -0
- package/engine/scripts/lib/preset-compat.mjs +127 -0
- package/engine/scripts/lib/preset-install.mjs +575 -0
- package/engine/scripts/lib/preset-mechanism.mjs +56 -0
- package/engine/scripts/lib/preset-sync.mjs +59 -0
- package/engine/scripts/lib/profile-retire.mjs +97 -0
- package/engine/scripts/lib/python-runtime.mjs +190 -0
- package/engine/scripts/lib/registry.mjs +72 -0
- package/engine/scripts/lib/repo-root.sh +56 -0
- package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
- package/engine/scripts/lib/schedule-probe.mjs +835 -0
- package/engine/scripts/lib/scope-pointer.mjs +164 -0
- package/engine/scripts/lib/settings-panels.mjs +816 -0
- package/engine/scripts/lib/stdio_utf8.py +86 -0
- package/engine/scripts/lib/wake-auth.mjs +392 -0
- package/engine/scripts/lib/webdriver_ipc.py +264 -0
- package/engine/scripts/lib/zstd-frames.mjs +104 -0
- package/engine/scripts/license-e2e-drill.mjs +426 -0
- package/engine/scripts/license-gate.mjs +245 -0
- package/engine/scripts/lip_backlog.py +171 -0
- package/engine/scripts/lip_table.py +185 -0
- package/engine/scripts/local-usage.mjs +214 -0
- package/engine/scripts/migrate-providers.mjs +127 -0
- package/engine/scripts/migrate-skills.sh +65 -0
- package/engine/scripts/ml-oauth-setup.mjs +550 -0
- package/engine/scripts/ml-store-ids.json +26 -0
- package/engine/scripts/ml_artifact_attribution.py +333 -0
- package/engine/scripts/ml_capacity_probe.py +1158 -0
- package/engine/scripts/ml_hub_status.py +1556 -0
- package/engine/scripts/ml_schema_check.py +115 -0
- package/engine/scripts/ml_store_ids.py +139 -0
- package/engine/scripts/npm-publish-argent.mjs +499 -0
- package/engine/scripts/npm-publish-argent.sh +20 -0
- package/engine/scripts/orchestrator.py +13811 -0
- package/engine/scripts/pack-plugins.mjs +140 -0
- package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
- package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
- package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
- package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
- package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
- package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
- package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
- package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
- package/engine/scripts/patches/verify-hardening.mjs +167 -0
- package/engine/scripts/patches/verify-session-migration.mjs +149 -0
- package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
- package/engine/scripts/plan-role-rename.mjs +125 -0
- package/engine/scripts/plugin-bootcheck.mjs +451 -0
- package/engine/scripts/plugin-composition.json +15 -0
- package/engine/scripts/poc-mcp-host.mjs +455 -0
- package/engine/scripts/poll-fulfillment.sh +293 -0
- package/engine/scripts/poll-invoice.sh +74 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +70 -0
- package/engine/scripts/provider-switch.sh +146 -0
- package/engine/scripts/provider-sync-target.check.mjs +64 -0
- package/engine/scripts/python-runtime.sh +78 -0
- package/engine/scripts/query_runner.py +310 -0
- package/engine/scripts/release-argent.sh +574 -0
- package/engine/scripts/repair-unresolved-tools.mjs +178 -0
- package/engine/scripts/run-tests.sh +256 -0
- package/engine/scripts/self-sign-license.mjs +179 -0
- package/engine/scripts/session-health-scan.mjs +63 -0
- package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
- package/engine/scripts/setup.sh +22 -0
- package/engine/scripts/skill-install.mjs +629 -0
- package/engine/scripts/sku_translate.py +148 -0
- package/engine/scripts/sync-plugins.sh +90 -0
- package/engine/scripts/sync-presets.sh +27 -0
- package/engine/scripts/sync-scripts.sh +445 -0
- package/engine/scripts/telemetry.py +873 -0
- package/engine/scripts/ui-copy-count.mjs +220 -0
- package/engine/scripts/usage-anchor.sh +136 -0
- package/engine/scripts/usage_report.py +531 -0
- package/engine/scripts/validate-preset-schema.mjs +208 -0
- package/engine/scripts/vertical-init.mjs +323 -0
- package/engine/scripts/vertical-lint.mjs +482 -0
- package/engine/scripts/wenyao-browser.sh +73 -0
- package/engine/scripts/wenyao-fixture-export.py +116 -0
- package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
- package/engine/scripts/wenyao-login.sh +34 -0
- package/engine/scripts/wenyao-template-map.json +292 -0
- package/engine/scripts/wenyao_attach.py +131 -0
- package/engine/scripts/wenyao_download_template.py +144 -0
- package/engine/scripts/wenyao_fill_template.py +568 -0
- package/engine/scripts/wenyao_login.py +145 -0
- package/engine/scripts/wenyao_portal.py +633 -0
- package/engine/scripts/wenyao_recon.py +230 -0
- package/engine/scripts/wenyao_run.py +503 -0
- package/engine/scripts/wenyao_shadow_diff.py +160 -0
- package/engine/scripts/wenyao_skill_contract.py +156 -0
- package/engine/scripts/wenyao_template_fetch.py +175 -0
- package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
- package/engine/scripts/ziniao-auth-probe.sh +268 -0
- package/engine/scripts/ziniao-keepalive.sh +216 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
- package/engine/scripts/ziniao-windows.sh +153 -0
- package/engine/version.mjs +1 -0
- package/engine/verticals/README.md +103 -0
- package/engine/verticals/_engine-words.baseline.txt +237 -0
- package/engine/verticals/_engine-words.json +19 -0
- package/engine/verticals/mercadolibre/README.md +43 -0
- package/engine/verticals/mercadolibre/config.json +709 -0
- package/engine/verticals/mercadolibre/flows.json +706 -0
- package/engine/verticals/mercadolibre/manifest.json +538 -0
- package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
- package/engine/verticals/mercadolibre/preflight.py +143 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
- package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
- package/engine/verticals/mercadolibre/prompts/full.md +91 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
- package/package.json +21 -4
- package/README.md +0 -3
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""flow_triage —— 流程调度/分诊:**本轮该派发什么**(只出计划,不执行)。
|
|
3
|
+
|
|
4
|
+
分工(见 docs/Argent-宿主适配契约.md §1「判断归属」):
|
|
5
|
+
flow_source 来源 → case(含字段缺口)
|
|
6
|
+
flow_triage 分诊 + 幂等/在飞/停滞/重试判定 → **派发计划**(本脚本)
|
|
7
|
+
(E3) 按计划派生 subagent 执行 —— 由会话/官方 subagent 承载,不在此脚本内
|
|
8
|
+
|
|
9
|
+
台账:~/.dsh/argent/flow-ledger.jsonl,每行一条事件
|
|
10
|
+
{"ts":..,"caseId":..,"event":"dispatched|done|failed|blocked","attempt":N,"error":".."}
|
|
11
|
+
判定规则(**一律按"有效末条事件"判定**,见 INFO_EVENTS):
|
|
12
|
+
· 最近事件 dispatched 且未超在飞窗口 → in_flight(跳过)
|
|
13
|
+
· 最近事件 dispatched 且已超窗口 → stale(需人工关注/重试)
|
|
14
|
+
· 最近事件 failed 且 attempt < max-attempts → retry(重试)
|
|
15
|
+
· 最近事件 failed 且 attempt >= 上限 → needs_human
|
|
16
|
+
· 无事件 → to_dispatch
|
|
17
|
+
· 最近事件 blocked → needs_human(等人工/运营;可见优于静默)
|
|
18
|
+
· 最近事件 done / failed_ack → 本轮不动(终态 / 人工已确认消案)
|
|
19
|
+
· 人工重新放行(Rule R,`pick.releaseReDispatch`):最近事件 failed/blocked(或
|
|
20
|
+
`flow_retry.py` 写的 retry)**且**放行位「就绪」=true ⇒ to_dispatch(带 kind=release),
|
|
21
|
+
受「每票上限 + 冷却」约束;超限/冷却中 ⇒ needs_human(写明原因)。
|
|
22
|
+
—— 2026-09-21 用户口径:运营自己补好数据、自己勾「就绪」就该自动重跑,不必运维介入。
|
|
23
|
+
· **上游数据变了 ⇒ 放行额度重置**(2026-10-13,修「加了上限之后再也不会被拾取」的 BUG;
|
|
24
|
+
生产实测某发票票(`<flow>__79406731`):运营按提示补好数据并重勾放行位,分诊却给
|
|
25
|
+
`release_capped`(3/3)⇒ **永远不再拾取**)。判据 = 该票**上游行走流程读取的字段**
|
|
26
|
+
相对**上次派发**的**稳定哈希**变了(⛔ 不是"又勾了一次就绪":放行勾本身**不参与**哈希)
|
|
27
|
+
⇒ 计数从 0 起算、写一条**信息性** `policy_apply`(`policy=release_budget_reset`)并放行;
|
|
28
|
+
⛔ 上游未变时**不许**重置(否则退化成无限重试)。
|
|
29
|
+
· **外部时序类失败不占运营放行额度**(2026-10-13,同一现场的第二条根因:「门户尚未生成/
|
|
30
|
+
标签未就绪」被记成"运营该补数据",运营白补了产品图):判据复用**现有失败分类器**
|
|
31
|
+
(`flow_failure.classify` 的 `needs_agent` 档 ⇒ 需判断/门户类)**或**该流程显式声明的
|
|
32
|
+
窄标记(`pick.releaseReDispatch.externalTiming.patterns`,如「外部时序/尚未生成/等待平台」)
|
|
33
|
+
⇒ 走**独立通道**:长冷却 + 独立更高上限,`planned` 记 `externalTiming=true`(⛔ **不写**
|
|
34
|
+
`release`)⇒ 既不占 `maxPerCase`、也不受其约束。
|
|
35
|
+
字段缺口按 owner 分类:**运营等人工字段缺失 → need_fields(落卡等人)**;
|
|
36
|
+
仅"系统"字段缺失 → 仍可派发(流程内回写)。
|
|
37
|
+
|
|
38
|
+
⛔ 两条**不可动摇**的可见性纪律(2026-10-09 生产实测 P0,某发票票):
|
|
39
|
+
① **信息性事件不得让票消失**:角色/引擎只写说明的事件(`phase` 叙述、`wake` 唤醒、
|
|
40
|
+
`policy_apply`/`policy_new` 政策记账)**不改变票据的工作状态** ⇒ 不能当判定依据。
|
|
41
|
+
判据 = 先跳过它们,回退到最近一条**状态性**事件再套上面的规则(实现见 `read_ledger` 的 `eff`)。
|
|
42
|
+
现场:尾部 = `blocked`(15:30:11) → **`phase`(15:30:59)** ⇒ 旧代码落空 ⇒ 分诊摘要全 0、
|
|
43
|
+
勾了「发票就绪」也永远不可派发、且**不在"需人工"里** = 静默卡死。
|
|
44
|
+
② **任何未被归入任何桶的 case 一律兜底进 needs_human**,reason 写明「分诊未归类(末条事件=…)」。
|
|
45
|
+
实现是**循环之后的对账**(`placed` 集合),不是逐分支 `if` —— 将来新增分支忘了归桶也逃不掉。
|
|
46
|
+
|
|
47
|
+
用法:
|
|
48
|
+
python3 scripts/flow_triage.py --flow wenyao-invoice [--limit 5] [--json]
|
|
49
|
+
python3 scripts/flow_triage.py --flow wenyao-invoice --cases-from-dir /tmp/cases # 离线/夹具
|
|
50
|
+
退出码:0 = 已产出计划;2 = 错误
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
import argparse, hashlib, json, pathlib, re, subprocess, sys, time, datetime as dt
|
|
54
|
+
|
|
55
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
56
|
+
REQ = HERE / 'flow-requirements.json'
|
|
57
|
+
SOURCE = HERE / 'flow_source.py'
|
|
58
|
+
sys.path.insert(0, str(HERE))
|
|
59
|
+
sys.path.insert(0, str(HERE / 'lib'))
|
|
60
|
+
from argent_datadir import resolve_data_dir # noqa: E402 数据目录唯一判据(勿在此复制链)
|
|
61
|
+
from flow_failure import classify # noqa: E402 与失败回路**同一套**失败判据
|
|
62
|
+
# 放行位真值判据与 apply_pick(flow_source)**同源**:飞书 checkbox 经 _flat() 变字符串,
|
|
63
|
+
# 'False' 是非空字符串 ⇒ 朴素 `if` 会误判成"已放行"。两处必须一致,故直接复用。
|
|
64
|
+
from flow_source import _truthy # noqa: E402
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
from flow_registry import consumer_doc as _registry_doc # P2a:base + 业务线合并(唯一真源)
|
|
68
|
+
except Exception: # noqa: BLE001 —— 半坏机器上退回单文件(升级链路要能跑)
|
|
69
|
+
def _registry_doc(path=None):
|
|
70
|
+
_p = pathlib.Path(path) if path else (HERE / "flow-requirements.json")
|
|
71
|
+
return json.loads(_p.read_text(encoding="utf-8"))
|
|
72
|
+
|
|
73
|
+
DEFAULT_LEDGER = pathlib.Path(resolve_data_dir()['dir']) / 'flow-ledger.jsonl'
|
|
74
|
+
|
|
75
|
+
# 人工重新放行(Rule R,2026-09-21)可重新派发的上游事件
|
|
76
|
+
RELEASE_UPSTREAM = ('failed', 'blocked')
|
|
77
|
+
|
|
78
|
+
# ── 放行额度重置(①,2026-10-13)──────────────────────────────────────────────
|
|
79
|
+
# 事件形状:`policy_apply` + `policy=release_budget_reset`。选 policy_apply 是因为它**已在
|
|
80
|
+
# INFO_EVENTS 里**(信息性事件不改变票据的工作状态 ⇒ 不会让判定依据漂移),且 flow_ledger 的
|
|
81
|
+
# EVENTS 白名单本就有它(⛔ 不加事件类型,向后兼容)。
|
|
82
|
+
RESET_POLICY = 'release_budget_reset'
|
|
83
|
+
RESET_DETAIL = '上游数据已变 ⇒ 放行额度重置'
|
|
84
|
+
# **外部时序**的内置兜底标记(`externalTiming.patterns` 未声明时用;流程声明优先)。
|
|
85
|
+
# 口径:只认"**平台/门户侧还没生成**"这类**我方与运营都改不动**的措辞 —— ⛔ 不认业务缺字段
|
|
86
|
+
# (缺字段必须照旧占运营放行额度,那是运营真能修的)。
|
|
87
|
+
EXTERNAL_BUILTIN_PATTERNS = (r'外部时序', r'尚未生成', r'未生成', r'等待平台', r'平台尚未', r'timing gap')
|
|
88
|
+
# 单元格值里这些键是**易变**的(带签名的临时 URL / 过期时间…):每次读都可能不同 ⇒
|
|
89
|
+
# 一律不进哈希(否则每轮都被判成"上游变了" ⇒ 无限重放)。飞书附件经 `_flat()` 后通常已是
|
|
90
|
+
# 文件名,这里是**防御性**收口(原始 dict 形态直传时也稳)。
|
|
91
|
+
_UPSTREAM_VOLATILE_KEY_HINTS = ('url', 'expire', 'thumbnail', 'token', 'sign')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pick_of(spec) -> dict:
|
|
95
|
+
"""该流程**生效**的 `pick`(变体优先):`flow_config` 正常时会解析到顶层;
|
|
96
|
+
解析失败(半坏机器)时退回 `source.variants.<default>.pick`(FULL 的 pick 写在那里)。"""
|
|
97
|
+
src = (spec or {}).get('source') or {}
|
|
98
|
+
pick = src.get('pick') or {}
|
|
99
|
+
if pick:
|
|
100
|
+
return pick
|
|
101
|
+
variants = src.get('variants') or {}
|
|
102
|
+
name = str(src.get('default') or '')
|
|
103
|
+
return ((variants.get(name) or {}).get('pick') or {})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def upstream_ignore(spec) -> set:
|
|
107
|
+
"""**不参与**"上游变化"判据的列名集合(唯一实现,两个通道共用)。
|
|
108
|
+
|
|
109
|
+
判据 = "流程**自己回写**的列" —— 它们变了不代表运营改了数据(否则每轮都"变了"⇒ 无限重试)。
|
|
110
|
+
「自己回写的列」按**既有口径**取,⛔ 不另立一套:
|
|
111
|
+
① `pick.releaseReDispatch.upstreamHash.ignore`(显式声明,最优先、最可审计);
|
|
112
|
+
② `guards.writeFields`(流程声明的写白名单;两个发票流程 = 发票状态/发票文件/大货标签/发票错误信息);
|
|
113
|
+
③ `guards.errorField`(失败收口必写的那一列);
|
|
114
|
+
④ `source.variants.*.fields` 的值并集(与 `orchestrator._full_allowed_fields` 同一判据 ——
|
|
115
|
+
FULL 的写白名单就是这么推出来的)。
|
|
116
|
+
另外**永远**排除:放行勾本身(勾一次 ≠ 改数据;这正是 BUG 的文案形态)、票号列
|
|
117
|
+
(它变了就是**另一张票**,caseId 都不同)、`_` 前缀的合成列(`_phase`/`_record_id`… 每轮重算)。
|
|
118
|
+
"""
|
|
119
|
+
src = (spec or {}).get('source') or {}
|
|
120
|
+
pick = pick_of(spec)
|
|
121
|
+
guards = (spec or {}).get('guards') or {}
|
|
122
|
+
up = (pick.get('releaseReDispatch') or {}).get('upstreamHash') or {}
|
|
123
|
+
out: set = set()
|
|
124
|
+
for name in (up.get('ignore') or []):
|
|
125
|
+
if str(name).strip():
|
|
126
|
+
out.add(str(name).strip())
|
|
127
|
+
if not out: # 未显式声明 ⇒ 按既有口径推(发票流程靠 writeFields 就够)
|
|
128
|
+
for name in (guards.get('writeFields') or []):
|
|
129
|
+
if str(name).strip():
|
|
130
|
+
out.add(str(name).strip())
|
|
131
|
+
if str(guards.get('errorField') or '').strip():
|
|
132
|
+
out.add(str(guards['errorField']).strip())
|
|
133
|
+
for v in (src.get('variants') or {}).values():
|
|
134
|
+
for val in ((v or {}).get('fields') or {}).values():
|
|
135
|
+
if isinstance(val, str) and val:
|
|
136
|
+
out.add(val)
|
|
137
|
+
rel_col = str((pick.get('release') or {}).get('field')
|
|
138
|
+
or (src.get('fields') or {}).get('ready') or '').strip()
|
|
139
|
+
if rel_col:
|
|
140
|
+
out.add(rel_col) # 放行勾**不参与**:勾一次 ≠ 运营改了数据
|
|
141
|
+
out.add(str(src.get('ticketField') or '货件号'))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def external_cfg(rd: dict) -> dict:
|
|
146
|
+
"""外部时序通道参数(缺省:开启 / 上限 8 / 长冷却 120 分钟)。"""
|
|
147
|
+
et = (rd or {}).get('externalTiming') or {}
|
|
148
|
+
return {'enabled': et.get('enabled', True) is not False,
|
|
149
|
+
'maxPerCase': int(et.get('maxPerCase') or 0) or 8,
|
|
150
|
+
'cooldownMin': int(et.get('cooldownMin') or 0) or 120,
|
|
151
|
+
'patterns': [str(x) for x in (et.get('patterns') or []) if str(x).strip()]}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def external_wait_copy(cooldown_min: int, ready_field: str) -> str:
|
|
155
|
+
"""**外部时序**阻塞时的**运营可见文案**(卡片 markdown 版;表字段版见发票链运行器的
|
|
156
|
+
`_label_wait_text` —— 同一句,那边按纯文本写、不加 `**`)。
|
|
157
|
+
|
|
158
|
+
两要素**缺一即判红**(回归 `scripts/tests/external-timing-ops-copy.test.py`):
|
|
159
|
+
① **自动再试节奏**:系统将在约 N 分钟后自动再试
|
|
160
|
+
—— N 取自声明面 `pick.releaseReDispatch.externalTiming.cooldownMin`,⛔ **不写死 120**;
|
|
161
|
+
② **人工接管方式**:现在人工处理完**取消勾选**「<放行位>」,系统就不再自动拾取。
|
|
162
|
+
为什么必须两句都给:运营看到"需人工"时只有两种正确动作 —— 等(节奏)或现在就自己处理(接管),
|
|
163
|
+
而"取消勾选放行位"正是后者的**唯一**开关(放行位为真才进本通道,见 `release_gate`)。
|
|
164
|
+
"""
|
|
165
|
+
rel = ready_field or '放行位'
|
|
166
|
+
return (f'系统将在约 {int(cooldown_min or 0)} 分钟后自动再试;'
|
|
167
|
+
f'你也可以现在人工处理(处理完**取消勾选**「{rel}」,系统就不再自动拾取)')
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _canon_scalar(v) -> str:
|
|
171
|
+
"""标量规范化:空白折叠、真/假归一、空值/None/False ⇒ `''`(`'False'` 同义)。"""
|
|
172
|
+
if v is None or v is False:
|
|
173
|
+
return ''
|
|
174
|
+
if v is True:
|
|
175
|
+
return 'true'
|
|
176
|
+
s = ' '.join(str(v).split())
|
|
177
|
+
return '' if s.lower() in ('false', 'none', 'null') else s
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _canon_value(v):
|
|
181
|
+
"""单元格值 → 稳定可比形态(规范化排序、忽略易变键;⛔ 不含任何业务解读)。"""
|
|
182
|
+
if isinstance(v, list):
|
|
183
|
+
parts = [_canon_value(x) for x in v]
|
|
184
|
+
parts = [p for p in parts if p not in ('', [], {})]
|
|
185
|
+
return sorted(parts, key=lambda x: json.dumps(x, ensure_ascii=False, sort_keys=True))
|
|
186
|
+
if isinstance(v, dict):
|
|
187
|
+
out = {}
|
|
188
|
+
for k, x in v.items():
|
|
189
|
+
kl = str(k).lower()
|
|
190
|
+
if any(h in kl for h in _UPSTREAM_VOLATILE_KEY_HINTS):
|
|
191
|
+
continue
|
|
192
|
+
out[str(k)] = _canon_value(x)
|
|
193
|
+
return out
|
|
194
|
+
return _canon_scalar(v)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def upstream_field_hashes(fields, ignore) -> dict:
|
|
198
|
+
"""上游行的**稳定哈希**(逐字段):`{字段名: sha256(规范值)[:8]}`(纯函数)。
|
|
199
|
+
|
|
200
|
+
· 参与 = case.fields − `ignore` − `_` 前缀合成列;空值不进("填了/清空"由**键的出现/消失**体现);
|
|
201
|
+
· 只保存**哈希**(8 位)不保存值 ⇒ 台账里不会留业务数据,理由文案也只点字段名(⛔ 不点值)。
|
|
202
|
+
"""
|
|
203
|
+
out = {}
|
|
204
|
+
for k, v in (fields or {}).items():
|
|
205
|
+
k = str(k)
|
|
206
|
+
if k.startswith('_') or k in ignore:
|
|
207
|
+
continue
|
|
208
|
+
cv = _canon_value(v)
|
|
209
|
+
if cv in ('', [], {}):
|
|
210
|
+
continue
|
|
211
|
+
blob = json.dumps(cv, ensure_ascii=False, sort_keys=True)
|
|
212
|
+
out[k] = hashlib.sha256(blob.encode('utf-8')).hexdigest()[:8]
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_ts(v) -> float:
|
|
217
|
+
"""ISO8601(`…+08:00` / `…Z`)或 epoch 数字 → epoch 秒;解析不了 ⇒ 0.0。"""
|
|
218
|
+
s = str(v or '').strip()
|
|
219
|
+
if not s:
|
|
220
|
+
return 0.0
|
|
221
|
+
try:
|
|
222
|
+
return float(s)
|
|
223
|
+
except Exception: # noqa: BLE001
|
|
224
|
+
pass
|
|
225
|
+
try:
|
|
226
|
+
if s.endswith('Z'):
|
|
227
|
+
s = s[:-1] + '+00:00'
|
|
228
|
+
return dt.datetime.fromisoformat(s).timestamp()
|
|
229
|
+
except Exception: # noqa: BLE001
|
|
230
|
+
return 0.0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def upstream_changed(case, spec, snap, last_disp_ts) -> tuple:
|
|
234
|
+
"""上游行相对**上次派发**是否**真的变了** → `(变化字段名列表, 判据来源)`(纯函数)。
|
|
235
|
+
|
|
236
|
+
两条判据(**有快照就用快照**,没有才退回 case 文件自带的时间戳):
|
|
237
|
+
|
|
238
|
+
· `ledger-snapshot` —— 台账里**上次派发**(`planned`)记的逐字段哈希(`upstreamFieldHash`,
|
|
239
|
+
由 `flow_claim` 写入)与当前值比;键出现/消失/哈希不同都算变。**最准**(不依赖时间戳)。
|
|
240
|
+
· `case-provenance` —— 0.1.7 既有台账**没有**这个字段(本次改动之前派的票),退回
|
|
241
|
+
`case.provenance.<字段>.at` = 该字段**最后一次变化**的时间(`flow_case.merge_case` 只在
|
|
242
|
+
值变化时更新它),比"上次派发时间"新 ⇒ 该字段变了。
|
|
243
|
+
⛔ **保守闸门**:case 文件比上次派发还新(`createdAt > 上次派发`,例如 case 目录被清过)
|
|
244
|
+
⇒ 时间戳没有可信基线 ⇒ **判"没变"**(宁可不重置,也不无限重试)。
|
|
245
|
+
· 两条都拿不到 ⇒ `([], 'none')`(**缺证据 ≠ 变了**)。
|
|
246
|
+
"""
|
|
247
|
+
ignore = upstream_ignore(spec)
|
|
248
|
+
cur = upstream_field_hashes((case or {}).get('fields') or {}, ignore)
|
|
249
|
+
old = (snap or {}).get('hash') if isinstance(snap, dict) else None
|
|
250
|
+
if isinstance(old, dict) and old:
|
|
251
|
+
old = {str(k): str(v) for k, v in old.items()}
|
|
252
|
+
changed = sorted([k for k in set(old) | set(cur) if old.get(k) != cur.get(k)])
|
|
253
|
+
return changed, 'ledger-snapshot'
|
|
254
|
+
prov = (case or {}).get('provenance') or {}
|
|
255
|
+
if not prov or not last_disp_ts:
|
|
256
|
+
return [], 'none'
|
|
257
|
+
created = _parse_ts((case or {}).get('createdAt'))
|
|
258
|
+
if created and created > float(last_disp_ts):
|
|
259
|
+
return [], 'none' # case 比上次派发还新 ⇒ 无可信基线
|
|
260
|
+
changed = []
|
|
261
|
+
for k in cur:
|
|
262
|
+
at = _parse_ts((prov.get(k) or {}).get('at'))
|
|
263
|
+
if at and at > float(last_disp_ts):
|
|
264
|
+
changed.append(k)
|
|
265
|
+
return (sorted(changed), 'case-provenance') if changed else ([], 'none')
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def is_external_timing(text, cfg) -> tuple:
|
|
269
|
+
"""这段失败文本是不是**外部时序**类(门户/平台侧还没生成)→ `(是否, 判据说明)`。
|
|
270
|
+
|
|
271
|
+
⛔ **不新造分类**:先看流程声明的窄标记(`externalTiming.patterns`,缺省用内置兜底),
|
|
272
|
+
再看**现有分类器** `flow_failure.classify` 的结论(`needs_agent` = 需判断/门户类)。
|
|
273
|
+
空白文本 ⇒ **不是**外部类(无从判定 ⇒ 照旧走 Rule R,避免把"没写原因"当成平台问题)。
|
|
274
|
+
"""
|
|
275
|
+
t = ' '.join(str(text or '').split())
|
|
276
|
+
if not t:
|
|
277
|
+
return False, ''
|
|
278
|
+
pats = [str(x) for x in ((cfg or {}).get('patterns') or []) if str(x).strip()] \
|
|
279
|
+
or list(EXTERNAL_BUILTIN_PATTERNS)
|
|
280
|
+
for p in pats:
|
|
281
|
+
try:
|
|
282
|
+
if re.search(p, t, re.I):
|
|
283
|
+
return True, f'外部时序标记 /{p}/'
|
|
284
|
+
except re.error:
|
|
285
|
+
continue
|
|
286
|
+
try:
|
|
287
|
+
kind = str((classify(t) or {}).get('kind') or '')
|
|
288
|
+
except Exception: # noqa: BLE001
|
|
289
|
+
kind = ''
|
|
290
|
+
if kind == 'needs_agent':
|
|
291
|
+
return True, '既有分类器判定 needs_agent(需判断/门户类)'
|
|
292
|
+
return False, ''
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def err_text(ev) -> str:
|
|
296
|
+
"""取一条失败/阻塞事件里的**失败原因**文本(`reason` → `error`;与 `flow_failure.reasons()` 同源口径)。
|
|
297
|
+
|
|
298
|
+
⛔ **刻意不读 `detail`**:它是通用的过程叙述(阶段说明/退出码/角色补白),不是失败原因 ——
|
|
299
|
+
实测(既有夹具 `A-BLOCK-PHASE` 的 `blocked{detail:"两卡发送失败"}`)把 detail 当原因是
|
|
300
|
+
**误判成外部时序**并把票送进独立通道。原因只认这两个字段;两者都空 ⇒ 无从判定 ⇒ 走 Rule R。
|
|
301
|
+
"""
|
|
302
|
+
if not isinstance(ev, dict):
|
|
303
|
+
return ''
|
|
304
|
+
for k in ('reason', 'error'):
|
|
305
|
+
s = ' '.join(str(ev.get(k) or '').split())
|
|
306
|
+
if s:
|
|
307
|
+
return s
|
|
308
|
+
return ''
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def write_reset(ledger: str, cid: str, changed, basis: str) -> bool:
|
|
312
|
+
"""写一条**信息性** `policy_apply`(`policy=release_budget_reset`)—— 计数基线归零。
|
|
313
|
+
|
|
314
|
+
⛔ 它不是 `release`:不占任何额度、不改变票据状态(`INFO_EVENTS` 已含 `policy_apply`)。
|
|
315
|
+
⛔ 只记**字段名**(`changed`),绝不记字段值。
|
|
316
|
+
写失败 ⇒ 返回 False(**fail-closed**:调用方不派发 —— 否则计数不归零 ⇒ 每轮都重置 ⇒ 无限重放)。
|
|
317
|
+
"""
|
|
318
|
+
if not ledger:
|
|
319
|
+
return False
|
|
320
|
+
try:
|
|
321
|
+
import flow_ledger as _L # noqa: PLC0415 同目录模块
|
|
322
|
+
_L.append(pathlib.Path(ledger), {
|
|
323
|
+
'ts': int(time.time()), 'caseId': cid, 'event': 'policy_apply',
|
|
324
|
+
'policy': RESET_POLICY, 'by': 'flow-triage',
|
|
325
|
+
'detail': f"{RESET_DETAIL}(变化字段:{'、'.join(changed)};判据:{basis})"[:300],
|
|
326
|
+
})
|
|
327
|
+
return True
|
|
328
|
+
except Exception as exc: # noqa: BLE001
|
|
329
|
+
print(f' ⚠ 放行额度重置事件写入失败({exc})⇒ 本轮不派发该票(计数未归零时重派会无限重放)',
|
|
330
|
+
file=sys.stderr)
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
# ── 有效末条事件(2026-10-09 P0)──────────────────────────────────────────────
|
|
334
|
+
# **信息性事件**:只带说明,不改变票据的工作状态 ⇒ 判定时必须跳过、回退到最近一条状态性事件。
|
|
335
|
+
# 逐条理由(都能在 `flow_ledger.py#EVENTS` 里对上):
|
|
336
|
+
# phase 角色写的阶段叙述("两卡发送失败"/"掌舵-汇报"…)——**本次 P0 的元凶**
|
|
337
|
+
# wake 引擎因失败唤醒角色(`flow_failure` 的交接动作)——票的状态没变
|
|
338
|
+
# policy_apply 依政策执行(`flow_policy.py`)——政策记账,不是票据进度
|
|
339
|
+
# policy_new 提议新政策(同上)
|
|
340
|
+
# ⛔ 刻意**不**收进来的(它们是状态性事件,必须参与判定):
|
|
341
|
+
# retry(Rule R 认 `by=flow_retry` 的那条)、requeued(停滞回收)、failed_ack(见 IDLE_EVENTS)
|
|
342
|
+
INFO_EVENTS = ('phase', 'wake', 'policy_apply', 'policy_new')
|
|
343
|
+
# **已确认事件**:人工确认/消案(`flow_failure.HANDLED_EVENTS` 同口径)⇒ 本轮**不动**,
|
|
344
|
+
# 且**不参与回退**(否则消案的票会退回它前面的 `failed`,每轮复活成 needs_human)。
|
|
345
|
+
IDLE_EVENTS = ('failed_ack',)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def load_flow(flow_id):
|
|
349
|
+
doc = _registry_doc()
|
|
350
|
+
# P2a:旧 id 经别名解析(`full` → `<vertical>.full`),否则业务线流程全查不到
|
|
351
|
+
canonical = (doc.get('aliases') or {}).get(flow_id, flow_id)
|
|
352
|
+
spec = (doc.get('flows') or {}).get(canonical)
|
|
353
|
+
# 字段要求与**字段名映射**都按当前变体取(flow_config 是唯一解析点):
|
|
354
|
+
# · required 决定缺口算谁的(影响 need_fields 分桶);
|
|
355
|
+
# · source.fields 里的 store 名字 —— 2026-09-18 修:变体流程(full 的 lip/legacy)
|
|
356
|
+
# 把字段映射写在 `source.variants.<名>.fields`,顶层 `source.fields` 是 null
|
|
357
|
+
# ⇒ 这里以前恒取到空字符串 ⇒ 每张票的 store 都是 ''。
|
|
358
|
+
# 后果(实测 15:34:15 那轮):flow_claim 按"虚拟空店"计同店并发 ⇒ perStore=1 变成
|
|
359
|
+
# **全局一次只跑一票**,跨店并行与 concurrency.max 全部失效(可派发 1 却认领 0 张)。
|
|
360
|
+
try:
|
|
361
|
+
import flow_config as _fc
|
|
362
|
+
_cfg = _fc.load_config(flow_id, strict=False) # P2a:不要传 base 路径(会退化成单文件)
|
|
363
|
+
if _cfg.get('required'):
|
|
364
|
+
spec = {**spec, 'required': _cfg['required']}
|
|
365
|
+
if _cfg.get('source'):
|
|
366
|
+
spec = {**spec, 'source': {**(spec.get('source') or {}), **(_cfg.get('source') or {})}}
|
|
367
|
+
# `guards` 也要合并:写白名单(`writeFields`/`errorField`)由 **capability** 提供
|
|
368
|
+
# (业务线 flows.json 里可能没写)—— ① 的"忽略自身回写列"判据要读它(见 upstream_ignore)。
|
|
369
|
+
if _cfg.get('guards'):
|
|
370
|
+
spec = {**spec, 'guards': {**(spec.get('guards') or {}), **(_cfg.get('guards') or {})}}
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
373
|
+
if not spec:
|
|
374
|
+
print(f'未知流程: {flow_id}', file=sys.stderr); sys.exit(2)
|
|
375
|
+
return spec
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def owners(spec):
|
|
379
|
+
"""字段 → 责任说明(**仅供展示**)。机器判据见 roles()。"""
|
|
380
|
+
out = {}
|
|
381
|
+
for r in spec.get('required') or []:
|
|
382
|
+
out.setdefault(r.get('name'), r.get('owner') or '')
|
|
383
|
+
return out
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def roles(spec):
|
|
387
|
+
"""字段 → 机器判据 role(input/state/output/derived);缺 role 时按旧 owner 规则兜底。
|
|
388
|
+
|
|
389
|
+
2026-09-20(迭代1-步骤2):此前"缺了要不要落卡等人"用的是 `'系统' not in owner`
|
|
390
|
+
—— 中文自由文本当判据,改名即静默翻转。现在只认显式 role:
|
|
391
|
+
· input ⇒ 缺了落卡等人
|
|
392
|
+
· state/output/derived ⇒ 不落卡等人(state 由流程或运营在运行中推进)
|
|
393
|
+
"""
|
|
394
|
+
out = {}
|
|
395
|
+
for r in spec.get('required') or []:
|
|
396
|
+
role = str(r.get('role') or '')
|
|
397
|
+
if not role: # 兜底(flow_config 会拒绝缺 role 的注册表)
|
|
398
|
+
role = 'output' if '系统' in str(r.get('owner') or '') else 'input'
|
|
399
|
+
out.setdefault(r.get('name'), role)
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def release_gate(spec, case):
|
|
404
|
+
"""人工重新放行(Rule R,2026-09-21)的三个量:配置 / 放行字段名 / 是否已放行。
|
|
405
|
+
|
|
406
|
+
放行字段与 apply_pick **同源**(`pick.release.field` → `source.fields.ready`),
|
|
407
|
+
值走 `flow_source._truthy`(`'False'`/`'否'`/空 ⇒ 未放行)。
|
|
408
|
+
"""
|
|
409
|
+
src = spec.get('source') or {}
|
|
410
|
+
pick = src.get('pick') or {}
|
|
411
|
+
rd = pick.get('releaseReDispatch') or {}
|
|
412
|
+
rel_col = ((pick.get('release') or {}).get('field')
|
|
413
|
+
or (src.get('fields') or {}).get('ready') or '')
|
|
414
|
+
val = (case.get('fields') or {}).get(rel_col) if rel_col else None
|
|
415
|
+
return rd, rel_col, _truthy(val)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def dispatch_rec(rec: dict, cur_hash, **extra) -> dict:
|
|
419
|
+
"""派发记录 = 判定记录 + 派发专属字段 + **当前上游逐字段哈希**。
|
|
420
|
+
|
|
421
|
+
为什么每一次派发都要带哈希:① 的判据是"上游相对**上次派发**变了吗" ⇒ 台账里必须有
|
|
422
|
+
"上次派发时上游长什么样"。`flow_claim.planned_record` 会把它抄进 `planned` 事件
|
|
423
|
+
(`upstreamFieldHash`),下一次分诊才有基线可比;⛔ 不记字段值,只记 8 位哈希。
|
|
424
|
+
"""
|
|
425
|
+
out = {**rec, **extra}
|
|
426
|
+
if cur_hash:
|
|
427
|
+
out['upstreamFieldHash'] = cur_hash
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def read_ledger(path: pathlib.Path) -> dict:
|
|
432
|
+
"""读台账 → {'last': {caseId: 末条事件}, 'eff': {caseId: 有效末条事件}, 'attempts': {...}, …}。
|
|
433
|
+
|
|
434
|
+
2026-09-18(实测 77445732 续跑成功后的乱序记账):角色的 `dispatched` 事件**记晚了** ——
|
|
435
|
+
实际链路是 `retry`(23:23) → 干完 `done`(23:30) → 才补记 `dispatched`(23:31)。
|
|
436
|
+
若只看"最后一行",这条**已经跑完**的票会被算成"在飞",30 分钟后变"停滞",
|
|
437
|
+
在工作台上显示成一条幽灵在飞票(正是用户此前抱怨过的"在飞不准")。
|
|
438
|
+
⇒ 已 `done` 的 case,之后补记的 `dispatched` **一律忽略**(done 是终态;票据状态
|
|
439
|
+
也会变成「已完成」而不进候选,所以不存在"done 之后还能再派发"的合法场景)。
|
|
440
|
+
|
|
441
|
+
2026-09-21(Rule R):额外统计**人工放行重跑**的次数与最近时间 ——
|
|
442
|
+
判据是认领记录 `planned` 带 `release=true`(flow_claim 写,仅 Rule R 候选带)。
|
|
443
|
+
用 planned 而不是"失败次数":上限约束的是**人工放行这条通道被用了几次**,
|
|
444
|
+
与流程内部重试(`auto_fail_retry`/`retry`)无关。
|
|
445
|
+
|
|
446
|
+
2026-10-09(P0,生产实测的某发票票:`blocked` 之后角色补了一条 `phase`):新增两套映射,**判定一律用 `eff`**:
|
|
447
|
+
· `last[cid]` = 台账末条事件(**原样**,含信息性事件)—— 用于诊断与 reason 文案;
|
|
448
|
+
· `eff[cid]` = **有效末条事件** = 最后一条**非** `INFO_EVENTS` 的事件。
|
|
449
|
+
只留末条会让"blocked 之后角色补一句 phase"的票从所有桶消失(分诊摘要全 0 ⇒ 静默卡死)。
|
|
450
|
+
`IDLE_EVENTS`(`failed_ack`)**算状态性**:人工已确认/消案的票**不许**退回它前面的 `failed`
|
|
451
|
+
每轮复活(那是把"消案"变成"没消案")。
|
|
452
|
+
|
|
453
|
+
2026-10-13(放行额度与上游变化):
|
|
454
|
+
· **额度从"上次重置"起算** —— 台账里的 `policy_apply{policy=release_budget_reset}`
|
|
455
|
+
(分诊在上游数据变化时写,见 `write_reset`)把该票的放行/外部两条通道计数**归零**。
|
|
456
|
+
计数按**行序**(事件在文件里的先后)判定,与 `ts` 同秒也不打架。
|
|
457
|
+
· 新增 `externalRetry`/`externalTs`(`planned{externalTiming=true}` 的计数,② 独立通道);
|
|
458
|
+
· 新增 `dispatchTs`(**任何** `planned` 的最近时间 = 外部通道的长冷却基线)与
|
|
459
|
+
`upstreamSnap`(最近一次派发记下的逐字段哈希 = ① 的"上次派发快照")。
|
|
460
|
+
`releaseRetry`/`releaseTs` 的**键名与语义不变**(只是把基线挪到最近一次重置之后)。
|
|
461
|
+
"""
|
|
462
|
+
last, eff, attempts, rel_cnt, rel_ts = {}, {}, {}, {}, {}
|
|
463
|
+
ext_cnt, ext_ts, disp_ts, upstream_snap, reset_idx = {}, {}, {}, {}, {}
|
|
464
|
+
rel_marks: dict = {} # cid → [(行号, ts)]:`planned{release=true}`
|
|
465
|
+
ext_marks: dict = {} # cid → [(行号, ts)]:`planned{externalTiming=true}`
|
|
466
|
+
disp_marks: dict = {} # cid → [(行号, ts)]:任何 `planned`
|
|
467
|
+
done_cases: set = set()
|
|
468
|
+
idx = 0
|
|
469
|
+
if not path.exists():
|
|
470
|
+
return {'last': last, 'eff': eff, 'attempts': attempts,
|
|
471
|
+
'releaseRetry': rel_cnt, 'releaseTs': rel_ts,
|
|
472
|
+
'externalRetry': ext_cnt, 'externalTs': ext_ts,
|
|
473
|
+
'dispatchTs': disp_ts, 'upstreamSnap': upstream_snap}
|
|
474
|
+
for line in path.read_text(encoding='utf-8').splitlines():
|
|
475
|
+
line = line.strip()
|
|
476
|
+
if not line:
|
|
477
|
+
continue
|
|
478
|
+
idx += 1
|
|
479
|
+
try:
|
|
480
|
+
e = json.loads(line)
|
|
481
|
+
except Exception:
|
|
482
|
+
continue
|
|
483
|
+
cid = e.get('caseId')
|
|
484
|
+
if not cid:
|
|
485
|
+
continue
|
|
486
|
+
# 放行额度重置(信息性政策记账):只当**计数基线**,⛔ 不当票据状态(故仍在 INFO_EVENTS 里)
|
|
487
|
+
if e.get('event') == 'policy_apply' and str(e.get('policy') or '') == RESET_POLICY:
|
|
488
|
+
reset_idx[cid] = idx
|
|
489
|
+
if e.get('event') == 'planned':
|
|
490
|
+
disp_marks.setdefault(cid, []).append((idx, float(e.get('ts') or 0)))
|
|
491
|
+
if e.get('release'):
|
|
492
|
+
rel_marks.setdefault(cid, []).append((idx, float(e.get('ts') or 0)))
|
|
493
|
+
if e.get('externalTiming'):
|
|
494
|
+
ext_marks.setdefault(cid, []).append((idx, float(e.get('ts') or 0)))
|
|
495
|
+
_h = e.get('upstreamFieldHash')
|
|
496
|
+
if isinstance(_h, dict) and _h:
|
|
497
|
+
upstream_snap[cid] = {'ts': float(e.get('ts') or 0),
|
|
498
|
+
'hash': {str(k): str(v) for k, v in _h.items()}}
|
|
499
|
+
else:
|
|
500
|
+
# 本次派发没记哈希(老版本写的 planned / 非 Rule R 流程)⇒ **清掉**上一次的快照:
|
|
501
|
+
# 快照必须恒等于"**上一次派发**时上游的样子",否则判据窗口会算错(比上次派发更早)。
|
|
502
|
+
upstream_snap.pop(cid, None)
|
|
503
|
+
if e.get('event') == 'done':
|
|
504
|
+
done_cases.add(cid)
|
|
505
|
+
# 已经 done 过、之后才补记的 dispatched ⇒ 迟到的记账,不作为"在飞"依据
|
|
506
|
+
if e.get('event') == 'dispatched' and cid in done_cases:
|
|
507
|
+
continue
|
|
508
|
+
last[cid] = e
|
|
509
|
+
if str(e.get('event') or '') not in INFO_EVENTS:
|
|
510
|
+
eff[cid] = e # 信息性事件不覆盖"有效末条"
|
|
511
|
+
if e.get('event') == 'failed':
|
|
512
|
+
attempts[cid] = max(attempts.get(cid, 0), int(e.get('attempt') or 1))
|
|
513
|
+
for cid, marks in rel_marks.items():
|
|
514
|
+
after = [t for i, t in marks if i > reset_idx.get(cid, 0)]
|
|
515
|
+
rel_cnt[cid] = len(after)
|
|
516
|
+
rel_ts[cid] = max(after) if after else 0.0
|
|
517
|
+
for cid, marks in ext_marks.items():
|
|
518
|
+
after = [t for i, t in marks if i > reset_idx.get(cid, 0)]
|
|
519
|
+
ext_cnt[cid] = len(after)
|
|
520
|
+
ext_ts[cid] = max(after) if after else 0.0
|
|
521
|
+
for cid, marks in disp_marks.items():
|
|
522
|
+
after = [t for i, t in marks if i > reset_idx.get(cid, 0)]
|
|
523
|
+
if after:
|
|
524
|
+
disp_ts[cid] = max(after)
|
|
525
|
+
return {'last': last, 'eff': eff, 'attempts': attempts,
|
|
526
|
+
'releaseRetry': rel_cnt, 'releaseTs': rel_ts,
|
|
527
|
+
'externalRetry': ext_cnt, 'externalTs': ext_ts,
|
|
528
|
+
'dispatchTs': disp_ts, 'upstreamSnap': upstream_snap}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
SOURCE_NOTES: list = [] # 源清单自带的"为什么没有待处理单元"(cases-json 用)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def collect_cases(args, spec) -> list[dict]:
|
|
535
|
+
"""cases-from-dir(离线)优先;否则调 flow_source 拉源。"""
|
|
536
|
+
if args.cases_from_dir:
|
|
537
|
+
out = []
|
|
538
|
+
for f in sorted(pathlib.Path(args.cases_from_dir).glob('*.json')):
|
|
539
|
+
out.append(json.loads(f.read_text(encoding='utf-8')))
|
|
540
|
+
return out
|
|
541
|
+
# 源类型由**流程声明**决定(P2:日报这类"单元不在表里"的流程用 cases-json 批量清单);
|
|
542
|
+
# 缺省 feishu-base,向后兼容既有流程。
|
|
543
|
+
src = (spec or {}).get('source') or {}
|
|
544
|
+
kind = str(src.get('kind') or 'feishu-base')
|
|
545
|
+
cmd = [sys.executable, str(SOURCE), '--flow', args.flow, '--source', kind, '--json']
|
|
546
|
+
if kind == 'cases-json':
|
|
547
|
+
cmd += ['--from-json', str(src.get('file') or '-')]
|
|
548
|
+
# 夹具/排障:显式要求"按现状读清单"(不跑再生成、不查过期)。
|
|
549
|
+
# 生产路径**不要**加它 —— 2026-09-20 的静默停摆正是因为该跑没跑。
|
|
550
|
+
if getattr(args, 'no_regenerate', False):
|
|
551
|
+
cmd += ['--no-regenerate']
|
|
552
|
+
if args.limit:
|
|
553
|
+
cmd += ['--limit', str(args.limit)]
|
|
554
|
+
if args.out_dir:
|
|
555
|
+
cmd += ['--out-dir', args.out_dir]
|
|
556
|
+
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
557
|
+
if not p.stdout.strip().startswith('{'):
|
|
558
|
+
print(f'flow_source 失败:{(p.stderr or p.stdout).strip()[:300]}', file=sys.stderr)
|
|
559
|
+
sys.exit(2)
|
|
560
|
+
plan = json.loads(p.stdout)
|
|
561
|
+
# 源清单的说明("已推送/未到点/窗口已过")——没有它,空清单只能显示成
|
|
562
|
+
# "无票可认领"(2026-09-18 用户实测:日报因此静默停摆一整天无人察觉)
|
|
563
|
+
SOURCE_NOTES.clear()
|
|
564
|
+
SOURCE_NOTES.extend([str(x) for x in (plan.get('notes') or [])][:6])
|
|
565
|
+
# 把 case 文件读回来(带 fields/provenance),与 gap 结果合并
|
|
566
|
+
out = []
|
|
567
|
+
for item in plan.get('cases') or []:
|
|
568
|
+
case_file = pathlib.Path(args.out_dir or (pathlib.Path(resolve_data_dir()['dir']) / 'flow-cases')) / f"{args.flow}__{item['ticket']}.json"
|
|
569
|
+
case = json.loads(case_file.read_text(encoding='utf-8')) if case_file.exists() else {'ticketKey': item['ticket'], 'fields': {}}
|
|
570
|
+
case['_missing'] = item.get('missing') or []
|
|
571
|
+
case['_gapError'] = item.get('gapError') # 缺口检查失败 ⇒ 绝不能当作"无缺口"
|
|
572
|
+
out.append(case)
|
|
573
|
+
return out
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def main(argv=None) -> int:
|
|
577
|
+
ap = argparse.ArgumentParser(description='流程分诊:产出本轮派发计划(只读)')
|
|
578
|
+
ap.add_argument('--flow', required=True)
|
|
579
|
+
ap.add_argument('--limit', type=int, help='本轮最多处理多少行(源读取上限)')
|
|
580
|
+
ap.add_argument('--no-regenerate', action='store_true',
|
|
581
|
+
help='不跑 source.regenerate、不查过期(仅夹具/排障)')
|
|
582
|
+
ap.add_argument('--cases-from-dir', help='离线:直接读该目录下的 case 文件(跳过源读取)')
|
|
583
|
+
ap.add_argument('--out-dir', help='case 落盘目录(默认 ~/.dsh/argent/flow-cases)')
|
|
584
|
+
ap.add_argument('--no-ledger-write', dest='no_ledger_write', action='store_true',
|
|
585
|
+
help='只读诊断:不写「放行额度重置」事件(仍照常给出计划)—— \n'
|
|
586
|
+
'给只读巡检用(customer-preflight),生产链路**不要**加')
|
|
587
|
+
ap.add_argument('--ledger', default=str(DEFAULT_LEDGER))
|
|
588
|
+
ap.add_argument('--inflight-min', type=int, default=30, help='在飞窗口(分钟),超过算停滞')
|
|
589
|
+
ap.add_argument('--max-attempts', type=int, default=2, help='失败重试上限')
|
|
590
|
+
ap.add_argument('--json', action='store_true')
|
|
591
|
+
args = ap.parse_args(argv)
|
|
592
|
+
|
|
593
|
+
spec = load_flow(args.flow)
|
|
594
|
+
own = owners(spec)
|
|
595
|
+
rl = roles(spec)
|
|
596
|
+
led = read_ledger(pathlib.Path(args.ledger))
|
|
597
|
+
now = time.time()
|
|
598
|
+
b = {'to_dispatch': [], 'need_fields': [], 'await_release': [], 'in_flight': [], 'stale': [], 'retry': [], 'needs_human': []}
|
|
599
|
+
|
|
600
|
+
recs: dict = {} # cid → rec(循环后兜底对账用;⛔ 不参与判定)
|
|
601
|
+
eff_ev: dict = {} # cid → 有效末条事件名(兜底 reason 用)
|
|
602
|
+
idle: set = set() # cid → **有意**本轮不动(done / failed_ack),兜底对账不得误报
|
|
603
|
+
for case in collect_cases(args, spec):
|
|
604
|
+
cid = case.get('caseId') or f"{args.flow}__{case.get('ticketKey')}"
|
|
605
|
+
# 2026-09-15 加固(文鳐实跑发现):缺口检查**失败**时绝不能当作"无缺口"——
|
|
606
|
+
# 实测:安装克隆缺 ml_schema_check.py ⇒ 子进程失败 ⇒ missing=[] ⇒ 误判可派发。
|
|
607
|
+
if case.get('_gapError'):
|
|
608
|
+
b['needs_human'].append({'caseId': cid, 'ticket': case.get('ticketKey'),
|
|
609
|
+
'reason': '缺口检查失败:' + str(case.get('_gapError'))[:120]})
|
|
610
|
+
continue
|
|
611
|
+
missing = case.get('_missing') or []
|
|
612
|
+
# 人工字段缺失 ⇒ 必须等人(系统字段缺失不阻塞派发)
|
|
613
|
+
# 机器判据=role(不再是 owner 的中文子串):只有 input 类缺口才等人
|
|
614
|
+
human_missing = [m for m in missing if rl.get(m, 'input') == 'input']
|
|
615
|
+
# ── 有效末条事件(2026-10-09 P0)──────────────────────────────────────
|
|
616
|
+
# `raw_last` = 台账末条(含 `phase` 这类信息性事件,**只用于诊断/文案**);
|
|
617
|
+
# `last` = **有效**末条(跳过 INFO_EVENTS)—— 下面所有判定一律用它。
|
|
618
|
+
raw_last = led['last'].get(cid)
|
|
619
|
+
last = led['eff'].get(cid)
|
|
620
|
+
tail = str(raw_last.get('event') or '') if (raw_last is not None and raw_last is not last) else ''
|
|
621
|
+
ev_now = str(last.get('event') or '') if last else ''
|
|
622
|
+
recs[cid] = None # 占位:rec 在下面建好后覆盖(兜底对账要保证"处理过")
|
|
623
|
+
eff_ev[cid] = ev_now
|
|
624
|
+
# 2026-09-17(用户口径 F5「triage 即识别层」):case 带 `_phase`
|
|
625
|
+
# identify = 状态为空的新行 ⇒ 派发去**识别**(校验授权/必填 → 写「待执行」或「跳过」+备注)
|
|
626
|
+
# execute = 已识别且已放行 ⇒ 正常作业
|
|
627
|
+
# await_release = 已识别但运营尚未勾「就绪」⇒ **只报数、不派发、不落卡**(等人放行)
|
|
628
|
+
phase = str((case.get('fields') or {}).get('_phase') or '')
|
|
629
|
+
rec = {'caseId': cid, 'ticket': case.get('ticketKey'), 'missing': missing, 'phase': phase,
|
|
630
|
+
'route': (case.get('fields') or {}).get((spec.get('routing') or {}).get('routeBy') or ''),
|
|
631
|
+
# P5(2026-09-17):带上 store —— claim 按 (flow, store) 限并发(同店串行、跨店并行)
|
|
632
|
+
'store': (case.get('fields') or {}).get(
|
|
633
|
+
((spec.get('source') or {}).get('fields') or {}).get('store') or '')}
|
|
634
|
+
if tail:
|
|
635
|
+
rec['tail'] = tail # 末条是信息性事件(已回退到有效末条判定)——留在证据里
|
|
636
|
+
recs[cid] = rec
|
|
637
|
+
|
|
638
|
+
if human_missing and not (last and last.get('event') in ('dispatched',)):
|
|
639
|
+
rec['humanMissing'] = human_missing
|
|
640
|
+
b['need_fields'].append(rec); continue
|
|
641
|
+
# 2026-09-17:**缺字段优先于"待放行"** —— 比如 FULL 的 91 行「状态=待执行但店铺为空」,
|
|
642
|
+
# 先报"缺店铺(等人补)"才有意义;否则会被 await_release 吞掉,看着像"只等放行"。
|
|
643
|
+
if phase == 'await_release':
|
|
644
|
+
b['await_release'].append({**rec, 'reason': '已识别、等运营勾「就绪」放行'})
|
|
645
|
+
continue
|
|
646
|
+
# ── 人工重新放行(Rule R,2026-09-21)──────────────────────────────────
|
|
647
|
+
# 用户口径(逐字):「如果是运营自行根据提示在表格数据上补充好了,并且打勾就绪,
|
|
648
|
+
# 那么 Argent 会自行重跑吗?」—— 此前答案是**不会**:下面 `failed`/`blocked` 两个
|
|
649
|
+
# 分支都不派,`retry` 桶又只有 flow_retry.py 一个消费者,于是失败过的行**永远**
|
|
650
|
+
# 停在原地(2026-09-21 实测:写表重置 + 勾就绪后两轮「可派发 0」)。
|
|
651
|
+
# 判据(全部确定性,无时间猜测):① 上游事件 ∈ {failed, blocked},或
|
|
652
|
+
# `flow_retry.py` 写的 retry(`by=flow_retry`;**角色**写的 retry 归角色自己派发,
|
|
653
|
+
# 引擎不抢 ⇒ 避免与 ruling/dispatch 双派);② 放行位为真 —— 失败收口一律写
|
|
654
|
+
# 就绪=false(`_fail_record` / SKU 未找到 / `_hand_to_ops_close`),所以为真
|
|
655
|
+
# 只可能是**人工重新勾的**;③ 未达 maxPerCase;④ 距上次人工放行重跑已过冷却。
|
|
656
|
+
rd, rel_col, released = release_gate(spec, case)
|
|
657
|
+
# 上游逐字段哈希:只要该流程开了 Rule R 通道就算一次(派发时随 `planned` 落台账 ⇒
|
|
658
|
+
# 下一轮的"上次派发快照";见 dispatch_rec)。
|
|
659
|
+
cur_hash = (upstream_field_hashes(case.get('fields') or {}, upstream_ignore(spec))
|
|
660
|
+
if rd.get('enabled') else None)
|
|
661
|
+
if last and rd.get('enabled'):
|
|
662
|
+
ev0, by0 = ev_now, str(last.get('by') or '')
|
|
663
|
+
upstream = ev0 in RELEASE_UPSTREAM or (ev0 == 'retry' and by0 == 'flow_retry')
|
|
664
|
+
if upstream and released:
|
|
665
|
+
err_txt = err_text(last)
|
|
666
|
+
et = external_cfg(rd)
|
|
667
|
+
is_ext, ext_why = is_external_timing(err_txt, et) if et['enabled'] else (False, '')
|
|
668
|
+
# ── ② 外部时序类 ⇒ **独立通道**(⛔ 不写 release、不占运营放行额度)────────
|
|
669
|
+
# 现场:`门户搜索订单 … 命中 0 行(仅缺大货标签)…外部时序缺口` 被判成
|
|
670
|
+
# "缺数据 ⇒ 运营该补",运营于是白补了产品图 —— 这类**运营改不动**,
|
|
671
|
+
# 该等平台生成,故给它自己的长冷却 + 更高上限,并**不吃** Rule R 的 3 次额度。
|
|
672
|
+
if is_ext:
|
|
673
|
+
ue = int(led['externalRetry'].get(cid, 0))
|
|
674
|
+
cap_e, cool_e = et['maxPerCase'], et['cooldownMin']
|
|
675
|
+
last_disp = float(led['dispatchTs'].get(cid) or 0)
|
|
676
|
+
if ue >= cap_e:
|
|
677
|
+
b['needs_human'].append({**rec, 'kind': 'external_capped', 'attempt': ue,
|
|
678
|
+
'reason': f"外部时序类({ext_why})已达外部通道上限"
|
|
679
|
+
f"({ue}/{cap_e})⇒ 交人核查门户/平台侧是否已生成"
|
|
680
|
+
f"(⛔ 不是运营数据问题:**取消勾选**「{rel_col}」即不再自动拾取)"})
|
|
681
|
+
continue
|
|
682
|
+
if last_disp and (now - last_disp) < cool_e * 60:
|
|
683
|
+
left = int((cool_e * 60 - (now - last_disp) + 59) // 60)
|
|
684
|
+
b['needs_human'].append({**rec, 'kind': 'external_cooldown',
|
|
685
|
+
'reason': f"外部时序类({ext_why})⇒ "
|
|
686
|
+
f"{external_wait_copy(cool_e, rel_col)}"
|
|
687
|
+
f"|本轮距上次派发还需约 {left} 分钟"
|
|
688
|
+
f"(长冷却 {cool_e} 分钟,**不占**运营放行额度)"})
|
|
689
|
+
continue
|
|
690
|
+
b['to_dispatch'].append(dispatch_rec(
|
|
691
|
+
rec, cur_hash, kind='external', externalRetry=ue + 1,
|
|
692
|
+
externalMax=cap_e, externalCooldownMin=cool_e, externalWhy=ext_why,
|
|
693
|
+
reason=f"外部时序类({ext_why})⇒ 走**独立通道**重派"
|
|
694
|
+
f"(第 {ue + 1}/{cap_e} 次;冷却 {cool_e} 分钟;"
|
|
695
|
+
f"⛔ 不占运营放行额度、也无需运营再勾「{rel_col}」)"))
|
|
696
|
+
continue
|
|
697
|
+
# ── ① 上游数据变化 ⇒ 放行额度重置(⛔ 不是"又勾了一次就绪")────────────
|
|
698
|
+
# 现场(某发票票 `79406731`):3 次放行重跑用满后再也不会被拾取,即便运营
|
|
699
|
+
# 已经补好数据。口径:额度约束的是"**同一份上游数据**被反复放行"。
|
|
700
|
+
used = int(led['releaseRetry'].get(cid, 0))
|
|
701
|
+
cap = int(rd.get('maxPerCase') or 0) or 3
|
|
702
|
+
cool = int(rd.get('cooldownMin') or 0)
|
|
703
|
+
last_rel = float(led['releaseTs'].get(cid) or 0)
|
|
704
|
+
cooling = bool(last_rel and cool and (now - last_rel) < cool * 60)
|
|
705
|
+
if used >= cap or cooling:
|
|
706
|
+
changed, basis = upstream_changed(case, spec, led['upstreamSnap'].get(cid),
|
|
707
|
+
led['dispatchTs'].get(cid))
|
|
708
|
+
if changed:
|
|
709
|
+
if not args.no_ledger_write and not write_reset(args.ledger, cid, changed, basis):
|
|
710
|
+
b['needs_human'].append({**rec, 'kind': 'release_reset_failed',
|
|
711
|
+
'attempt': used,
|
|
712
|
+
'reason': f'上游数据已变(变化字段:{"、".join(changed)})'
|
|
713
|
+
f'但**额度重置事件写不进台账** ⇒ 本轮不派发'
|
|
714
|
+
f'(没有归零就重派会变成无限重放):请查台账可写性'})
|
|
715
|
+
continue
|
|
716
|
+
b['to_dispatch'].append(dispatch_rec(
|
|
717
|
+
rec, cur_hash, kind='release', releaseRetry=1, upstreamReset=True,
|
|
718
|
+
changedFields=changed, upstreamHashBasis=basis,
|
|
719
|
+
reason=f"上游数据已变(变化字段:{'、'.join(changed)};"
|
|
720
|
+
f"判据:{basis})⇒ 放行额度重置,重新派发(第 1/{cap} 次)"))
|
|
721
|
+
continue
|
|
722
|
+
if used >= cap:
|
|
723
|
+
b['needs_human'].append({**rec, 'kind': 'release_capped', 'attempt': used,
|
|
724
|
+
'reason': f'人工放行重跑已达上限({used}/{cap})且上游数据'
|
|
725
|
+
f'自上次派发**未变** ⇒ 交人:先查为什么每次都失败;'
|
|
726
|
+
f'再勾「{rel_col}」不会重跑,'
|
|
727
|
+
f'**改好上游数据(本流程读取的任一字段)后额度自动重置**'})
|
|
728
|
+
continue
|
|
729
|
+
if cooling:
|
|
730
|
+
left = int((cool * 60 - (now - last_rel) + 59) // 60)
|
|
731
|
+
b['needs_human'].append({**rec, 'kind': 'release_cooldown',
|
|
732
|
+
'reason': f'人工放行重跑冷却中(还需约 {left} 分钟)'})
|
|
733
|
+
continue
|
|
734
|
+
b['to_dispatch'].append(dispatch_rec(
|
|
735
|
+
rec, cur_hash, kind='release', releaseRetry=used + 1,
|
|
736
|
+
reason=f'运营人工放行({rel_col}=true)⇒ 允许重新派发'
|
|
737
|
+
f'(人工放行第 {used + 1}/{cap} 次)'))
|
|
738
|
+
continue
|
|
739
|
+
if raw_last is None:
|
|
740
|
+
b['to_dispatch'].append(dispatch_rec(rec, cur_hash)); continue # 台账里**一条事件都没有** ⇒ 新行
|
|
741
|
+
if last is None:
|
|
742
|
+
# 只有信息性事件、没有任何状态事件(2026-10-09):**不能**当新行派发 ——
|
|
743
|
+
# `phase` 说明有人在动这张票(而它没走 `planned` 认领)⇒ 盲派即可能双跑。
|
|
744
|
+
# 可见优于静默 ⇒ 交人;也绝不静默消失。
|
|
745
|
+
b['needs_human'].append({**rec, 'kind': 'info_only',
|
|
746
|
+
'reason': f"台账只有信息性事件(末条 {tail or 'phase'}),"
|
|
747
|
+
f"没有任何状态事件 ⇒ 无法判定是否在跑(⛔ 不自动重派以免双跑)"
|
|
748
|
+
f":请人工确认这张票该派发、该等还是该作废"})
|
|
749
|
+
continue
|
|
750
|
+
# 停滞回收(2026-09-23):flow_stale 写了 requeued ⇒ 这张票重新可派发。
|
|
751
|
+
# 没有这条规则时,停滞票**永远不会**回到 to_dispatch(claim 只取 to_dispatch)⇒
|
|
752
|
+
# 环境故障留下的票只能靠人发现(实测:可派发 6 却连续 4 轮无票可认领)。
|
|
753
|
+
if ev_now == 'requeued':
|
|
754
|
+
b['to_dispatch'].append(dispatch_rec(rec, cur_hash, kind='stale-reclaim')); continue
|
|
755
|
+
ev, ts = ev_now, float(last.get('ts') or 0)
|
|
756
|
+
age_min = (now - ts) / 60 if ts else 1e9
|
|
757
|
+
if ev == 'planned':
|
|
758
|
+
# 2026-09-15:**认领(planned)= 在飞**(带租约)—— 防"唤起后、派发前"的重复唤起。
|
|
759
|
+
lease_until = float(last.get('leaseUntil') or 0)
|
|
760
|
+
if lease_until and now > lease_until:
|
|
761
|
+
b['stale'].append({**rec, 'reason': '认领租约到期未派发(可回收重派)'})
|
|
762
|
+
else:
|
|
763
|
+
b['in_flight'].append({**rec, 'planned': True,
|
|
764
|
+
'leaseMinLeft': round(max(0.0, (lease_until - now) / 60), 1) if lease_until else None})
|
|
765
|
+
continue
|
|
766
|
+
if ev == 'dispatched':
|
|
767
|
+
(b['in_flight'] if age_min < args.inflight_min else b['stale']).append({**rec, 'ageMin': round(age_min, 1)})
|
|
768
|
+
elif ev == 'failed':
|
|
769
|
+
att = led['attempts'].get(cid, 1)
|
|
770
|
+
# P3.6(2026-09-17 实测不一致):只看 attempt 次数会把"缺数据 / 选错行"的票判成**可重试**
|
|
771
|
+
# ⇒ 派下去必然再失败(白跑一轮 + 白烧一个 subagent 轮次)。
|
|
772
|
+
# 改为问一次**错误分类器**(与失败回路同一套判据):
|
|
773
|
+
# retryable(网络/限流,未到上限) ⇒ retry(允许重派)
|
|
774
|
+
# 其余(needs_human 缺数据/选错行、needs_agent 需判断) ⇒ needs_human(**不许自动派发**)
|
|
775
|
+
err_txt = str(last.get('error') or '').strip()
|
|
776
|
+
if err_txt:
|
|
777
|
+
try:
|
|
778
|
+
kind = classify(err_txt, att, args.max_attempts)['kind']
|
|
779
|
+
except Exception:
|
|
780
|
+
kind = 'retryable' if att < args.max_attempts else 'needs_agent'
|
|
781
|
+
else:
|
|
782
|
+
# 没有错误文本 = 无从判断原因 ⇒ 沿用 attempt 规则(兼容历史数据/简化夹具)
|
|
783
|
+
kind = 'retryable' if att < args.max_attempts else 'needs_agent'
|
|
784
|
+
if kind == 'retryable' and att < args.max_attempts:
|
|
785
|
+
b['retry'].append({**rec, 'attempt': att, 'kind': kind})
|
|
786
|
+
else:
|
|
787
|
+
b['needs_human'].append({**rec, 'attempt': att, 'kind': kind,
|
|
788
|
+
'reason': f'分类器判定 {kind}(缺数据/需人/需判断 ⇒ 不自动重派)'})
|
|
789
|
+
elif ev in ('done',) or ev in IDLE_EVENTS:
|
|
790
|
+
idle.add(cid) # 已完成 / 人工已确认(消案)⇒ 本轮不动(**有意**不进任何桶,非静默漏判)
|
|
791
|
+
elif ev == 'blocked':
|
|
792
|
+
# 2026-10-09(与 P0 同族的生产实测):blocked = **等人工/运营**,旧代码 `pass`
|
|
793
|
+
# ⇒ 票从分诊摘要里彻底消失(摘要「需人工 0」而票确实卡着)。现在**可见**。
|
|
794
|
+
hint = (f";补齐数据后勾「{rel_col}」即自动重跑(无需通知运维)"
|
|
795
|
+
if (rd.get('enabled') and rel_col) else ";该流程未开「人工放行重跑」⇒ 请人工裁定")
|
|
796
|
+
b['needs_human'].append({**rec, 'kind': 'blocked',
|
|
797
|
+
'reason': f"台账末条 blocked(= 等人工/运营处置)"
|
|
798
|
+
f"{'(其后只有信息性事件 ' + tail + ')' if tail else ''}{hint}"})
|
|
799
|
+
else:
|
|
800
|
+
# 状态性事件但**没有对应分支**(未知事件类型 / 将来新增的事件)—— 到不了这里也没关系:
|
|
801
|
+
# 循环后的对账兜底会把"处理过、却没进任何桶"的 case 收进 needs_human。
|
|
802
|
+
pass
|
|
803
|
+
# ── 兜底对账(2026-10-09 P0,**结构上**不可能漏)────────────────────────────
|
|
804
|
+
# 判据 = "处理过的 case" − "已进桶的 case";**不依赖**上面任何分支写了 `continue`/`append`。
|
|
805
|
+
# ⇒ 将来新增事件类型/新增分支忘了归桶,票也只会**可见地**落进 needs_human,绝不静默消失。
|
|
806
|
+
placed = {str(r.get('caseId')) for v in b.values() for r in v}
|
|
807
|
+
unclassified = []
|
|
808
|
+
for cid, rec in recs.items():
|
|
809
|
+
if cid in placed or cid in idle or not isinstance(rec, dict):
|
|
810
|
+
continue
|
|
811
|
+
unclassified.append(cid)
|
|
812
|
+
raw_ev = str((led['last'].get(cid) or {}).get('event') or '无')
|
|
813
|
+
eff = str(eff_ev.get(cid) or '无')
|
|
814
|
+
why = f"分诊未归类(末条事件={raw_ev}" + (f",有效末条={eff}" if eff != raw_ev else "") + ")"
|
|
815
|
+
b['needs_human'].append({**rec, 'kind': 'unclassified',
|
|
816
|
+
'reason': f"{why} ⇒ 请人工确认这张票该派发、等人还是作废(可见优于静默)"})
|
|
817
|
+
summary = {k: len(v) for k, v in b.items()}
|
|
818
|
+
# 人工放行重跑在日志里**单独报数**:运维看一行日志就能判断"勾了就绪到底有没有用"
|
|
819
|
+
rel_n = len([r for r in b['to_dispatch'] if str(r.get('kind') or '') == 'release'])
|
|
820
|
+
# 「上游数据变化 ⇒ 额度重置」与「外部时序独立通道」也**单独报数**(③:机制与文案一致,
|
|
821
|
+
# 运维一眼能看出"运营改了数据到底有没有生效""这张票是不是平台侧还没生成")。
|
|
822
|
+
reset_n = len([r for r in b['to_dispatch'] if r.get('upstreamReset')])
|
|
823
|
+
ext_n = len([r for r in b['to_dispatch'] if str(r.get('kind') or '') == 'external'])
|
|
824
|
+
# 信息性事件回退数也单独报数:≥1 说明"末条是 phase/wake"这类票本来会**静默消失**
|
|
825
|
+
info_n = len([1 for r in recs.values() if isinstance(r, dict) and r.get('tail')])
|
|
826
|
+
if args.json:
|
|
827
|
+
print(json.dumps({'flow': args.flow, 'summary': summary, 'plan': b,
|
|
828
|
+
'releaseRetry': rel_n, 'releaseReset': reset_n, 'externalReDispatch': ext_n,
|
|
829
|
+
'infoFallback': info_n,
|
|
830
|
+
'unclassified': sorted(unclassified), 'notes': SOURCE_NOTES},
|
|
831
|
+
ensure_ascii=False, indent=2))
|
|
832
|
+
else:
|
|
833
|
+
print(f" 流程 {args.flow}|本轮:可派发 {summary['to_dispatch']}(其中人工放行重跑 {rel_n}"
|
|
834
|
+
+ (f"、上游变化重置 {reset_n}" if reset_n else "")
|
|
835
|
+
+ (f"、外部时序通道 {ext_n}" if ext_n else "") + ")"
|
|
836
|
+
f"|待放行 {summary['await_release']}|等人补字段 {summary['need_fields']}"
|
|
837
|
+
f"|在飞 {summary['in_flight']}|停滞 {summary['stale']}|可重试 {summary['retry']}|需人工 {summary['needs_human']}"
|
|
838
|
+
+ (f"|信息性事件回退 {info_n}" if info_n else ""))
|
|
839
|
+
if SOURCE_NOTES:
|
|
840
|
+
print(' · 源清单说明:' + ';'.join(SOURCE_NOTES))
|
|
841
|
+
for k in ('to_dispatch', 'await_release', 'need_fields', 'stale', 'retry', 'needs_human'):
|
|
842
|
+
for r in b[k]:
|
|
843
|
+
extra = ('缺: ' + '、'.join(r.get('humanMissing') or r.get('missing') or [])[:60]) if (r.get('humanMissing') or r.get('missing')) else ''
|
|
844
|
+
print(f" [{k}] {r['ticket']} {extra}")
|
|
845
|
+
return 0
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
if __name__ == '__main__':
|
|
849
|
+
raise SystemExit(main())
|