@whyshu/argent-engine 0.0.0-stage → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/MANIFEST.json +341 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +576 -0
  55. package/engine/scripts/billing-mode-daily.sh +13 -0
  56. package/engine/scripts/build-engine-package.mjs +126 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +378 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/collect-evidence.mjs +42 -0
  81. package/engine/scripts/config-explain.mjs +278 -0
  82. package/engine/scripts/configure-provider.rb +29 -0
  83. package/engine/scripts/customer-config-migrate.mjs +179 -0
  84. package/engine/scripts/customer-preflight.py +892 -0
  85. package/engine/scripts/customer-readiness.sh +135 -0
  86. package/engine/scripts/daily-report-inbox.py +242 -0
  87. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  88. package/engine/scripts/daily-report.mjs +1310 -0
  89. package/engine/scripts/daily-report.sh +47 -0
  90. package/engine/scripts/data/lip-product-dict.json +306 -0
  91. package/engine/scripts/deliver-customer.mjs +932 -0
  92. package/engine/scripts/delivery-faces.txt +31 -0
  93. package/engine/scripts/desktop-trial.sh +146 -0
  94. package/engine/scripts/dev-window.sh +261 -0
  95. package/engine/scripts/drill-clean-machine.sh +315 -0
  96. package/engine/scripts/dsh-api-client.mjs +215 -0
  97. package/engine/scripts/dsh-backup-scope.sh +83 -0
  98. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  99. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  100. package/engine/scripts/dsh-plane.mjs +153 -0
  101. package/engine/scripts/dsh-portforward.py +223 -0
  102. package/engine/scripts/dsh-preflight.mjs +179 -0
  103. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  104. package/engine/scripts/dsh-release-watch.mjs +458 -0
  105. package/engine/scripts/dsh-remote-check.sh +99 -0
  106. package/engine/scripts/dsh-restart.sh +179 -0
  107. package/engine/scripts/dsh-restore.sh +111 -0
  108. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  109. package/engine/scripts/dsh-snapshot.mjs +116 -0
  110. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  111. package/engine/scripts/dsh-upgrade.mjs +612 -0
  112. package/engine/scripts/dsh-url.sh +86 -0
  113. package/engine/scripts/dsh-web-launch.mjs +155 -0
  114. package/engine/scripts/ensure-store-windows.py +509 -0
  115. package/engine/scripts/entitlements.py +458 -0
  116. package/engine/scripts/env_probe.py +411 -0
  117. package/engine/scripts/feishu-card.mjs +252 -0
  118. package/engine/scripts/final-check.mjs +43 -0
  119. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  120. package/engine/scripts/flow-auto-retry.py +454 -0
  121. package/engine/scripts/flow-requirements.json +735 -0
  122. package/engine/scripts/flow-scaffold.py +275 -0
  123. package/engine/scripts/flow-timers-plist.sh +87 -0
  124. package/engine/scripts/flow-timers.sh +184 -0
  125. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  126. package/engine/scripts/flow-trigger.sh +571 -0
  127. package/engine/scripts/flow_billing.py +354 -0
  128. package/engine/scripts/flow_capability_stats.py +314 -0
  129. package/engine/scripts/flow_case.py +194 -0
  130. package/engine/scripts/flow_claim.py +497 -0
  131. package/engine/scripts/flow_config.py +1025 -0
  132. package/engine/scripts/flow_failure.py +588 -0
  133. package/engine/scripts/flow_learn.py +105 -0
  134. package/engine/scripts/flow_ledger.py +235 -0
  135. package/engine/scripts/flow_policy.py +238 -0
  136. package/engine/scripts/flow_preflight.py +339 -0
  137. package/engine/scripts/flow_registry.py +341 -0
  138. package/engine/scripts/flow_retry.py +304 -0
  139. package/engine/scripts/flow_return_to_ops.py +191 -0
  140. package/engine/scripts/flow_source.py +315 -0
  141. package/engine/scripts/flow_sources/__init__.py +53 -0
  142. package/engine/scripts/flow_sources/cases_json.py +106 -0
  143. package/engine/scripts/flow_sources/common.py +428 -0
  144. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  145. package/engine/scripts/flow_stale.py +185 -0
  146. package/engine/scripts/flow_status_reconcile.py +344 -0
  147. package/engine/scripts/flow_triage.py +849 -0
  148. package/engine/scripts/flow_write_verify.py +496 -0
  149. package/engine/scripts/fulfillment.js +586 -0
  150. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  151. package/engine/scripts/gen-preset-rows.mjs +259 -0
  152. package/engine/scripts/init-ml.sh +2278 -0
  153. package/engine/scripts/install-internal-role.sh +190 -0
  154. package/engine/scripts/install-license.mjs +641 -0
  155. package/engine/scripts/install-one-shot.mjs +665 -0
  156. package/engine/scripts/integration_contract.py +220 -0
  157. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  158. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  159. package/engine/scripts/invoice-mapping.example.json +40 -0
  160. package/engine/scripts/invoice-table.sh +195 -0
  161. package/engine/scripts/invoice_carrier_notice.py +279 -0
  162. package/engine/scripts/invoice_fail_closeout.py +297 -0
  163. package/engine/scripts/invoice_lip_runner.py +1508 -0
  164. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  165. package/engine/scripts/invoice_ready_audit.py +191 -0
  166. package/engine/scripts/invoice_runner.py +675 -0
  167. package/engine/scripts/invoice_stageb.py +516 -0
  168. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  169. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  170. package/engine/scripts/lease.py +377 -0
  171. package/engine/scripts/lib/agent-install.mjs +649 -0
  172. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  173. package/engine/scripts/lib/argent-datadir.sh +18 -0
  174. package/engine/scripts/lib/argent_datadir.py +285 -0
  175. package/engine/scripts/lib/argent_filelock.py +507 -0
  176. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  177. package/engine/scripts/lib/cdp_ports.py +197 -0
  178. package/engine/scripts/lib/customer-env.mjs +102 -0
  179. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  180. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  181. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  182. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  183. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  184. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  185. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  186. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  187. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  188. package/engine/scripts/lib/patch-compose.mjs +241 -0
  189. package/engine/scripts/lib/patch-target.mjs +35 -0
  190. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  191. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  192. package/engine/scripts/lib/preset-compat.mjs +127 -0
  193. package/engine/scripts/lib/preset-install.mjs +575 -0
  194. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  195. package/engine/scripts/lib/preset-sync.mjs +59 -0
  196. package/engine/scripts/lib/profile-retire.mjs +97 -0
  197. package/engine/scripts/lib/python-runtime.mjs +190 -0
  198. package/engine/scripts/lib/registry.mjs +72 -0
  199. package/engine/scripts/lib/repo-root.sh +56 -0
  200. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  201. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  202. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  203. package/engine/scripts/lib/settings-panels.mjs +816 -0
  204. package/engine/scripts/lib/stdio_utf8.py +86 -0
  205. package/engine/scripts/lib/wake-auth.mjs +392 -0
  206. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  207. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  208. package/engine/scripts/license-e2e-drill.mjs +426 -0
  209. package/engine/scripts/license-gate.mjs +245 -0
  210. package/engine/scripts/lip_backlog.py +171 -0
  211. package/engine/scripts/lip_table.py +185 -0
  212. package/engine/scripts/local-usage.mjs +214 -0
  213. package/engine/scripts/migrate-providers.mjs +127 -0
  214. package/engine/scripts/migrate-skills.sh +65 -0
  215. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  216. package/engine/scripts/ml-store-ids.json +26 -0
  217. package/engine/scripts/ml_artifact_attribution.py +333 -0
  218. package/engine/scripts/ml_capacity_probe.py +1158 -0
  219. package/engine/scripts/ml_hub_status.py +1556 -0
  220. package/engine/scripts/ml_schema_check.py +115 -0
  221. package/engine/scripts/ml_store_ids.py +139 -0
  222. package/engine/scripts/npm-publish-argent.mjs +499 -0
  223. package/engine/scripts/npm-publish-argent.sh +20 -0
  224. package/engine/scripts/orchestrator.py +13811 -0
  225. package/engine/scripts/pack-plugins.mjs +140 -0
  226. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  227. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  228. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  229. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  230. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  231. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  232. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  233. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  234. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  235. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  236. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  237. package/engine/scripts/plan-role-rename.mjs +125 -0
  238. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  239. package/engine/scripts/plugin-composition.json +15 -0
  240. package/engine/scripts/poc-mcp-host.mjs +455 -0
  241. package/engine/scripts/poll-fulfillment.sh +295 -0
  242. package/engine/scripts/poll-invoice.sh +76 -0
  243. package/engine/scripts/profile-doctor.mjs +686 -0
  244. package/engine/scripts/provider-select.sh +71 -0
  245. package/engine/scripts/provider-switch.sh +146 -0
  246. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  247. package/engine/scripts/python-runtime.sh +78 -0
  248. package/engine/scripts/query_runner.py +310 -0
  249. package/engine/scripts/release-argent.sh +574 -0
  250. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  251. package/engine/scripts/retire-inventory.mjs +40 -0
  252. package/engine/scripts/run-tests.sh +257 -0
  253. package/engine/scripts/s1-audit.mjs +85 -0
  254. package/engine/scripts/self-sign-license.mjs +179 -0
  255. package/engine/scripts/session-health-scan.mjs +63 -0
  256. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  257. package/engine/scripts/setup.sh +22 -0
  258. package/engine/scripts/skill-install.mjs +629 -0
  259. package/engine/scripts/sku_translate.py +148 -0
  260. package/engine/scripts/sync-plugins.sh +90 -0
  261. package/engine/scripts/sync-presets.sh +27 -0
  262. package/engine/scripts/sync-scripts.sh +445 -0
  263. package/engine/scripts/telemetry.py +873 -0
  264. package/engine/scripts/ui-copy-count.mjs +220 -0
  265. package/engine/scripts/usage-anchor.sh +137 -0
  266. package/engine/scripts/usage_report.py +531 -0
  267. package/engine/scripts/validate-preset-schema.mjs +208 -0
  268. package/engine/scripts/vertical-init.mjs +323 -0
  269. package/engine/scripts/vertical-lint.mjs +482 -0
  270. package/engine/scripts/wenyao-browser.sh +74 -0
  271. package/engine/scripts/wenyao-fixture-export.py +116 -0
  272. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  273. package/engine/scripts/wenyao-login.sh +35 -0
  274. package/engine/scripts/wenyao-template-map.json +292 -0
  275. package/engine/scripts/wenyao_attach.py +131 -0
  276. package/engine/scripts/wenyao_download_template.py +144 -0
  277. package/engine/scripts/wenyao_fill_template.py +568 -0
  278. package/engine/scripts/wenyao_login.py +145 -0
  279. package/engine/scripts/wenyao_portal.py +633 -0
  280. package/engine/scripts/wenyao_recon.py +230 -0
  281. package/engine/scripts/wenyao_run.py +503 -0
  282. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  283. package/engine/scripts/wenyao_skill_contract.py +156 -0
  284. package/engine/scripts/wenyao_template_fetch.py +175 -0
  285. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  286. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  287. package/engine/scripts/ziniao-keepalive.sh +217 -0
  288. package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
  289. package/engine/scripts/ziniao-windows.sh +154 -0
  290. package/engine/skills/README.md +86 -0
  291. package/engine/skills/argent-dev/SKILL.md +41 -0
  292. package/engine/skills/internal.json +37 -0
  293. package/engine/skills/lktop-pm/SKILL.md +35 -0
  294. package/engine/skills/lktopapp-dev/SKILL.md +34 -0
  295. package/engine/skills/ml-config-guide/SKILL.md +50 -0
  296. package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
  297. package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
  298. package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
  299. package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
  300. package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
  301. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
  302. package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
  303. package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
  304. package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
  305. package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
  306. package/engine/skills/ml-invoice/SKILL.md +506 -0
  307. package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
  308. package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
  309. package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
  310. package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
  311. package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
  312. package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
  313. package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
  314. package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
  315. package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
  316. package/engine/skills/pet-dev/SKILL.md +42 -0
  317. package/engine/skills/whyshu-pm/SKILL.md +35 -0
  318. package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
  319. package/engine/version.mjs +1 -0
  320. package/engine/verticals/README.md +103 -0
  321. package/engine/verticals/_engine-words.baseline.txt +237 -0
  322. package/engine/verticals/_engine-words.json +19 -0
  323. package/engine/verticals/mercadolibre/README.md +43 -0
  324. package/engine/verticals/mercadolibre/config.json +709 -0
  325. package/engine/verticals/mercadolibre/flows.json +738 -0
  326. package/engine/verticals/mercadolibre/manifest.json +538 -0
  327. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  328. package/engine/verticals/mercadolibre/preflight.py +143 -0
  329. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  330. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  331. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  332. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  333. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  334. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  335. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  336. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  337. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  338. package/package.json +22 -4
  339. package/requirements.txt +5 -0
  340. package/README.md +0 -3
@@ -0,0 +1,497 @@
1
+ #!/usr/bin/env python3
2
+ """flow_claim —— **原子认领**待跑票(防重复跑同一票)。
3
+
4
+ 为什么需要(2026-09-15 实测竞态):
5
+ 定时触发 T=0 看到 in_flight=0 ⇒ 唤起角色;角色要几十秒才派发并记账;
6
+ T=+5min 下一个触发:台账里仍 in_flight=0 ⇒ **又唤起一次 ⇒ 同一票被派两遍**。
7
+ ⇒ 唤起前必须先**原子认领**:写 `planned`(带租约,默认 10 分钟),triage 把它计入在飞;
8
+ 角色只处理被认领的票;派发时升级为 `dispatched`;到期未升级 ⇒ triage 视为可回收(stale)。
9
+
10
+ 用法:
11
+ python3 scripts/flow_claim.py --flow wenyao-invoice [--max 2] [--lease-min 10] [--json] [--dry-run]
12
+ python3 scripts/flow_claim.py --flow <flow> --cases-from-dir /tmp/cases --json # 离线/夹具
13
+ · `--max/--lease-min` 缺省取**流程配置**(`flow_config.py` ← 注册表 concurrency 段);
14
+ · 锁**按流程分文件**(`flow-claim-<flow>.lock`)—— 两条流程的触发互不阻塞;
15
+ · `--cases-from-dir` **原样透传给分诊**(`flow_triage.py:217`)⇒ 认领也能在无客户、无网络、
16
+ 无许可的机器上端到端跑通(P4 离线冒烟 S3 的前置)。
17
+ 退出码:0 有认领(或 dry-run);3 = 无票可认领;2 = 错误
18
+ ⚠️ 本脚本的退出码**不在** P0-A 的 verb 退出码表管辖内(改它会连带动 scripts/flow-trigger.sh 的
19
+ headless 分支)⇒ `argent flow claim` 由 **verb 适配层**把 3 翻成 `0 + empty:true`(见 core/lib/claim.mjs)。
20
+ dry-run 零副作用(P0-A A3):不建台账目录、不建锁文件、不落店铺清单缓存、不写 blocked 台账,
21
+ 连源层的 case 落盘也改到临时目录 ⇒ 跑两次 dry-run 后数据目录**逐字节不变**。
22
+ """
23
+ from __future__ import annotations
24
+ import argparse, json, os, pathlib, re, subprocess, sys, tempfile, time
25
+ from typing import Optional
26
+
27
+ HERE = pathlib.Path(__file__).resolve().parent
28
+ sys.path.insert(0, str(HERE))
29
+ sys.path.insert(0, str(HERE / 'lib'))
30
+ from flow_config import load_config # noqa: E402 (每流程运行时配置的唯一读取点)
31
+ from argent_datadir import resolve_data_dir # noqa: E402 数据目录唯一判据(勿在此复制链)
32
+ # 跨平台文件锁**唯一真源**(POSIX `fcntl.flock` / Windows `msvcrt.locking`):
33
+ # 此前本文件顶层 `import fcntl` ⇒ Windows 上 `ModuleNotFoundError` ⇒ 认领整个不可用。
34
+ from argent_filelock import FileLock, LockError, best_effort # noqa: E402
35
+
36
+ def _ziniao_stores(ttl_sec: int = 600, write: bool = True) -> set:
37
+ """紫鸟店铺清单(**逐字**匹配用,2026-09-23 用户口径 A)。带 TTL 缓存;
38
+ 取不到返回空集 ⇒ 调用方跳过校验(宁可不拦,也不误杀干净票)。
39
+
40
+ `write=False`(dry-run):**只读缓存**、不落盘 —— A3 要求 dry-run 零持久副作用,
41
+ 而缓存落盘会改动数据目录(哪怕只是性能缓存)。判据不受影响(清单内容同一次调用内一致)。
42
+ """
43
+ cache = pathlib.Path(resolve_data_dir()['dir']) / 'ziniao-stores.json'
44
+ try:
45
+ doc = json.loads(cache.read_text(encoding='utf-8'))
46
+ if time.time() - float(doc.get('ts') or 0) < ttl_sec and doc.get('stores'):
47
+ return set(doc['stores'])
48
+ except Exception:
49
+ pass
50
+ try:
51
+ out = subprocess.run(['ziniao-cli', 'store', 'list'], capture_output=True, text=True, timeout=45).stdout
52
+ d = json.loads(out[out.find('{'):]) if '{' in out else {}
53
+ names = {str(it.get('storeName') or it.get('name') or '').strip()
54
+ for it in ((d.get('data') or {}).get('items') or [])}
55
+ names.discard('')
56
+ if names and write:
57
+ cache.parent.mkdir(parents=True, exist_ok=True)
58
+ cache.write_text(json.dumps({'ts': time.time(), 'stores': sorted(names)}, ensure_ascii=False), encoding='utf-8')
59
+ return names
60
+ except Exception:
61
+ return set()
62
+
63
+
64
+ def _ziniao_store_ips(ttl_sec: int = 600, write: bool = True) -> dict:
65
+ """店铺名 → 代理 IP(`ziniao-cli store list`,TTL 缓存)。取不到 ⇒ 空 dict。
66
+
67
+ B3(2026-09-24)为什么需要:**限流按代理 IP 判定**,而我们的并发限制一直是"按店铺名"——
68
+ 实测本机 6 家店里有**两对主/子账号共用同一个代理**:
69
+ · `216.238.78.250` ← 墨西哥-1店-子账号 + 墨西哥-1店-主账号
70
+ · `158.23.227.110` ← 墨西哥-2店-子账号 + 墨西哥-2店-主账号
71
+ 主/子账号是**两个不同的店名** ⇒ 旧口径允许它们并行 ⇒ 同一个 IP 上请求速率翻倍
72
+ (限流正是这样被触发的)。按 IP 计并发才是正确单位。
73
+
74
+ `write=False`(dry-run):只读缓存、不落盘(A3 零副作用;判据不受影响)。
75
+ """
76
+ cache = pathlib.Path(resolve_data_dir()['dir']) / 'ziniao-store-ips.json'
77
+ try:
78
+ doc = json.loads(cache.read_text(encoding='utf-8'))
79
+ if time.time() - float(doc.get('ts') or 0) < ttl_sec and doc.get('ips'):
80
+ return {str(k): str(v) for k, v in doc['ips'].items()}
81
+ except Exception:
82
+ pass
83
+ try:
84
+ out = subprocess.run(['ziniao-cli', 'store', 'list'], capture_output=True, text=True, timeout=45).stdout
85
+ d = json.loads(out[out.find('{'):]) if '{' in out else {}
86
+ ips = {str(it.get('storeName') or it.get('name') or '').strip(): str(it.get('ip') or '').strip()
87
+ for it in ((d.get('data') or {}).get('items') or [])}
88
+ ips = {k: v for k, v in ips.items() if k}
89
+ if ips and write:
90
+ cache.parent.mkdir(parents=True, exist_ok=True)
91
+ cache.write_text(json.dumps({'ts': time.time(), 'ips': ips}, ensure_ascii=False), encoding='utf-8')
92
+ return ips
93
+ except Exception:
94
+ return {}
95
+
96
+
97
+ TRIAGE = HERE / 'flow_triage.py'
98
+
99
+
100
+ def append(path: pathlib.Path, rec: dict) -> None:
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ with open(path, 'a', encoding='utf-8') as f:
103
+ # 台账**追加写**:策略 (b) 响亮降级(旧实现在 Windows 上是**静默**无锁写 —— 这里至少出声)。
104
+ # 「不阻断主流程」是台账自身的约定;真正防重复派发的是上面那把认领锁(策略 (a))。
105
+ with best_effort(f, site='flow_claim.append', note='台账追加降级为无锁写'):
106
+ f.write(json.dumps(rec, ensure_ascii=False) + '\n'); f.flush(); os.fsync(f.fileno())
107
+
108
+
109
+ def live_leases(ledger: pathlib.Path, now: float) -> dict:
110
+ """从台账取**未过期租约**的按店计数:{store: 数量}。
111
+
112
+ 口径(2026-09-18 修,用户实测"同店两条 SKU 要等 10 分钟?"暴露):
113
+ 某 case 仍占租约 ⇔ **该 case 的最新事件是 `planned`** 且 `leaseUntil > now`。
114
+ 为什么必须看"最新事件":租约是"已认领、还没跑完"的标记,跑完写 `done`(或 `failed`/
115
+ `blocked`)后**租约就该释放**。旧实现只按 `planned + leaseUntil > now` 计数,**不看
116
+ 收尾事件** ⇒ 跑完的票仍按租约占位(full 租约 30 分钟)⇒ 同店下一张要白等 30 分钟
117
+ (实测:墨西哥-1店 16:07:49 就跑完,16:1x 仍被算作"在飞 1",租约到 16:30:37)。
118
+ 与 flow_triage / 工作台的在飞口径一致(都是"按 case 最新事件"判定)。
119
+ """
120
+ last: dict = {}
121
+ try:
122
+ for line in ledger.read_text(encoding='utf-8').splitlines():
123
+ if not line.strip():
124
+ continue
125
+ try:
126
+ e = json.loads(line)
127
+ except Exception:
128
+ continue
129
+ cid = e.get('caseId')
130
+ if cid:
131
+ last[cid] = e
132
+ except FileNotFoundError:
133
+ pass
134
+ by_store: dict = {}
135
+ for e in last.values():
136
+ if e.get('event') != 'planned':
137
+ continue # 已被 done/failed/blocked/phase 收尾 ⇒ 不再占租约
138
+ if float(e.get('leaseUntil') or 0) <= now:
139
+ continue
140
+ st = str(e.get('store') or '')
141
+ by_store[st] = by_store.get(st, 0) + 1
142
+ return by_store
143
+
144
+
145
+ def select_candidates(cands: list, live_by_store: dict, max_n: int,
146
+ per_store: int, batch_per_store: int = 1,
147
+ per_ip: int = 0, store_ip: dict | None = None) -> list:
148
+ """按店选票(纯函数,便于离线回归)。
149
+
150
+ 两种模式(同一套判据,`batch_per_store` 切换):
151
+
152
+ · `batch_per_store <= 1`(缺省/旧行为):同店**同时**最多 `per_store` 张 ——
153
+ "同店串行、跨店并行"。`per_store <= 0` ⇒ 不限制(不依赖店铺浏览器的流程,如发票)。
154
+
155
+ · `batch_per_store > 1`(**一店一批**,2026-09-18 用户口径):一个店**一次带走一批**
156
+ (最多 `batch_per_store` 张),由**一个** subagent 跑
157
+ `orchestrator.py --records a,b,…`(KB-085:单 CDP 连接、多标签页、**顺序**执行)。
158
+ 铁律:**该店已有活租约(上一批还在跑)⇒ 整店跳过,绝不追加** ——
159
+ 追加 = 同店两批并行,而产物认领的兜底是"目录里新出现/最新的文件"
160
+ (`_snapshot_download_dir` 差值),同店并行会**把标签/箱唛记到错票**。
161
+
162
+ 没有店铺信息的票(store 为空)按**同一个虚拟店**计数 —— 保守处理:宁少派,不抢资源。
163
+
164
+ · `per_ip > 0`(B3,2026-09-24):在"按店"之上再加一层**按代理 IP** 的限制。
165
+ 限流按 IP 判定,而主/子账号是**两个店名、同一个 IP**(本机实测两对)⇒ 只按店限并发
166
+ 等于允许同一 IP 上翻倍打请求。IP 未知的店归入**同一个"未知"桶**(保守:也不许叠加)。
167
+ """
168
+ picked, take = [], {}
169
+ batch = int(batch_per_store or 1)
170
+ ip_used: dict = {}
171
+ if per_ip > 0:
172
+ for st, live_n in (live_by_store or {}).items():
173
+ ip = (store_ip or {}).get(st) or '__unknown__'
174
+ if live_n:
175
+ ip_used[ip] = ip_used.get(ip, 0) + int(live_n)
176
+ for c in cands:
177
+ if len(picked) >= max_n:
178
+ break
179
+ st = str(c.get('store') or '')
180
+ live = live_by_store.get(st, 0)
181
+ if batch > 1 and live > 0:
182
+ continue # 该店上一批还在跑 ⇒ 整店跳过(绝不追加)
183
+ used = live + take.get(st, 0)
184
+ cap = batch if batch > 1 else per_store
185
+ if cap > 0 and used >= cap:
186
+ continue
187
+ ip = ((store_ip or {}).get(st) or '__unknown__') if per_ip > 0 else None
188
+ if ip is not None and ip_used.get(ip, 0) >= per_ip:
189
+ continue # 同 IP 已达上限(含"其它店名共用一个代理"的情况)
190
+ take[st] = take.get(st, 0) + 1
191
+ if ip is not None:
192
+ ip_used[ip] = ip_used.get(ip, 0) + 1
193
+ picked.append(c)
194
+ return picked
195
+
196
+
197
+ def planned_record(cid: str, store: str, now: int, lease_until: int, cand: dict | None = None) -> dict:
198
+ """认领记录(`planned`)。
199
+
200
+ 2026-09-21(Rule R):人工放行重跑(triage 给的候选带 `kind=release`)额外记
201
+ `release=true` + `releaseRetry=N` —— 分诊的「每票上限 / 冷却」就认这两个字段
202
+ (只数人工放行这条通道,与流程内部重试无关)。
203
+
204
+ 2026-10-13(放行额度与上游变化):
205
+ · `kind=external`(**外部时序**独立通道)记 `externalTiming=true`,⛔ **不写** `release`
206
+ ⇒ 既不占 `maxPerCase`、也不受它约束(分诊按 `externalRetry` 单独计数);
207
+ · 任何派发都抄下 triage 给的 `upstreamFieldHash`(逐字段 8 位哈希,**不含值**)——
208
+ 它是下一轮"上游相对**上次派发**变了吗"的基线(① 的判据);`upstreamReset` 只作
209
+ 诊断标记(说明这一次派发是"上游变了 ⇒ 额度重置"换来的)。
210
+ 纯函数,便于离线回归。
211
+ """
212
+ rec = {'ts': now, 'caseId': cid, 'event': 'planned', 'store': store,
213
+ 'leaseUntil': lease_until, 'by': 'flow-claim'}
214
+ kind = str((cand or {}).get('kind') or '')
215
+ if kind == 'release':
216
+ rec['release'] = True
217
+ rec['releaseRetry'] = int((cand or {}).get('releaseRetry') or 0)
218
+ elif kind == 'external':
219
+ rec['externalTiming'] = True
220
+ rec['externalRetry'] = int((cand or {}).get('externalRetry') or 0)
221
+ _h = (cand or {}).get('upstreamFieldHash')
222
+ if isinstance(_h, dict) and _h:
223
+ rec['upstreamFieldHash'] = {str(k): str(v) for k, v in _h.items()}
224
+ if (cand or {}).get('upstreamReset'):
225
+ rec['upstreamReset'] = True
226
+ return rec
227
+
228
+
229
+ # ── 平台限流信号(店级退避的**唯一**判据表,分两类)──────────────────────
230
+ # 【确定类】平台限流的"原样回法"——前端模块加载失败也在内。见到即退避。
231
+ THROTTLE_PATTERNS = ('local_rate_limited', '429', '503', 'rate_limit', '限流',
232
+ 'fbm-shipment-planning-frontend', 'resolveProps')
233
+ # 【可疑类】ML 的"整页错误页"形态——**它既可能是限流,也可能是别的原因**(2026-09-23 实测):
234
+ # · 17:12 搜索页(步3,尚无货件号)收到「Hubo un error accediendo…/Error Code: 88WX9XT9M」;
235
+ # · 17:30 预约页(步4)整页「Algo salió mal/Estamos trabajando para solucionarlo」——
236
+ # 但**改号后立刻正常**(77849070→77849071,⑰ 取号错位第 3 次),即这次根因是**错号**不是限流。
237
+ # ⇒ 判据必须带**步位闸**:只有"还不涉及货件号的阶段"(无 phase / step1~3)出现的整页错误页
238
+ # 才算平台限流证据;步4/6/7 的同类错误页先当成"可能是数据/号码问题",不触发店级退避
239
+ # (否则一次错号会让整店白白停 30 分钟自动认领)。
240
+ THROTTLE_AMBIGUOUS = ('platform_error_page', 'Hubo un error accediendo',
241
+ 'Algo salió mal', 'Estamos trabajando para solucionarlo')
242
+ # 可疑类只在这些步位(含"无步位信息")算证据:≤3 = 建单前后、还没有货件号可错
243
+ THROTTLE_AMBIGUOUS_MAX_STEP = 3
244
+ # 只有"处置类"事件才算限流证据:done(成功收口)的 detail 里也常复述错误弹窗自愈过程,
245
+ # 拿它当证据会让一次**成功**的运行把同店白白退避 30 分钟。
246
+ THROTTLE_EVENTS = ('failed', 'blocked', 'retry', 'requeued')
247
+
248
+
249
+ def throttle_step_of(event: dict) -> Optional[int]:
250
+ """从台账事件里取步位(`phase="step4"` / 错误文本里的 `步骤4`/`step 4`)→ int 或 None。"""
251
+ m = re.search(r'step\s*(\d)', str(event.get('phase') or ''), re.I) \
252
+ or re.search(r'步骤\s*(\d)', str(event.get('error') or '') + str(event.get('detail') or ''))
253
+ return int(m.group(1)) if m else None
254
+
255
+
256
+ def throttled_stores(ledger: pathlib.Path, now: float, cooldown_min: int,
257
+ store_of: dict) -> dict:
258
+ """最近 cooldown_min 分钟内被平台限流过的店铺 → {store: 最近一次时间戳}(纯函数,便于离线回归)。
259
+
260
+ - 只看 `THROTTLE_EVENTS` 类事件;
261
+ - 确定类信号见到即算;可疑类(整页错误页)必须过**步位闸**(见 THROTTLE_AMBIGUOUS 注释);
262
+ - 未识别出店铺的事件不计(宁少退避,不误伤别的店)。
263
+ """
264
+ hot: dict = {}
265
+ try:
266
+ for line in ledger.read_text(encoding='utf-8').splitlines()[-400:]:
267
+ try:
268
+ e = json.loads(line)
269
+ except json.JSONDecodeError:
270
+ continue
271
+ if str(e.get('event') or '') not in THROTTLE_EVENTS:
272
+ continue
273
+ ts = float(e.get('ts') or 0)
274
+ if now - ts > cooldown_min * 60:
275
+ continue
276
+ blob = str(e.get('detail') or '') + str(e.get('error') or '')
277
+ hit = any(k in blob for k in THROTTLE_PATTERNS)
278
+ if not hit and any(k in blob for k in THROTTLE_AMBIGUOUS):
279
+ step = throttle_step_of(e)
280
+ hit = step is None or step <= THROTTLE_AMBIGUOUS_MAX_STEP
281
+ if hit:
282
+ st = store_of.get(str(e.get('caseId'))) or str(e.get('store') or '')
283
+ if st:
284
+ hot[st] = max(hot.get(st, 0), ts)
285
+ except OSError:
286
+ pass
287
+ return hot
288
+
289
+
290
+ def main(argv=None) -> int:
291
+ ap = argparse.ArgumentParser(description='原子认领待跑票(planned + 租约)')
292
+ ap.add_argument('--flow', required=True)
293
+ ap.add_argument('--max', type=int, default=None, help='本轮最多认领几张(默认取流程配置 concurrency.max)')
294
+ ap.add_argument('--lease-min', type=int, default=None, help='租约分钟(默认取流程配置 concurrency.leaseMin)')
295
+ ap.add_argument('--ledger', default=None, help='台账文件(默认取流程配置 paths.ledger)')
296
+ ap.add_argument('--lock', help='锁文件(默认按流程分文件 flow-claim-<flow>.lock)')
297
+ ap.add_argument('--batch', type=int, default=None,
298
+ help='一店一批:同店单轮最多带走几张(默认取流程配置 concurrency.batchPerStore)')
299
+ ap.add_argument('--per-ip', dest='per_ip', type=int, default=None,
300
+ help='按代理 IP 限并发(B3;默认取流程配置 concurrency.perIp,0=不限)')
301
+ ap.add_argument('--json', action='store_true')
302
+ ap.add_argument('--dry-run', action='store_true')
303
+ ap.add_argument('--cases-from-dir', dest='cases_from_dir', default=None,
304
+ help='离线/夹具:把该目录**原样透传**给分诊(跳过源读取)——无网络机器上也能跑认领')
305
+ a = ap.parse_args(argv)
306
+
307
+ cfg = load_config(a.flow) # 未知流程在此 exit 2(早失败,提示可用流程)
308
+ max_n = a.max if a.max is not None else cfg['concurrency']['max']
309
+ lease_min = a.lease_min if a.lease_min is not None else cfg['concurrency']['leaseMin']
310
+ batch_n = a.batch if a.batch is not None else int(cfg['concurrency'].get('batchPerStore') or 1)
311
+ lock = pathlib.Path(a.lock or cfg['paths']['lock'])
312
+ ledger = pathlib.Path(a.ledger or cfg['paths']['ledger'])
313
+
314
+ # 整个"读 triage → 选票 → 写认领"必须在**同一把锁**内完成,否则并发触发仍会重复选
315
+ # ── dry-run 零副作用(P0-A A3;2026-10-03)──────────────────────────────
316
+ # dry-run 不许在数据目录里**新建任何东西**:不建台账目录、不建锁文件。
317
+ # · 台账目录:真跑才 mkdir(dry-run 只读台账,缺目录时按"无台账"读)。
318
+ # · 锁:数据目录里**已有**锁文件(生产机器第一次真认领之后就有)⇒ 照用,与真跑互斥;
319
+ # 没有 ⇒ 退到临时目录,只与其它 dry-run 互斥(预演不该在生产目录留痕)。
320
+ if not a.dry_run:
321
+ ledger.parent.mkdir(parents=True, exist_ok=True)
322
+ elif not lock.exists():
323
+ lock = pathlib.Path(tempfile.gettempdir()) / f'argent-claim-dry-{lock.name}'
324
+ # ── 认领互斥锁:**策略 (a) fail-closed**(跨平台,见 scripts/lib/filelock.py)──────────
325
+ # 为什么这里绝不降级:整个「读 triage → 选票 → 写 planned」必须原子,丢了锁就会把同一张票
326
+ # 派两遍(重复跑同一票 = 重复写生产表)。所以拿不到/锁不可用 ⇒ **明确失败**(退出码 2),
327
+ # 而不是无锁继续;旧实现在 Windows 上连注释都执行不到(顶层 import fcntl 直接崩)。
328
+ # 语义与旧实现逐字等价:同一把锁文件、阻塞式排他锁、锁的生命周期 = fd(进程退出即释放)。
329
+ _claim_lock = FileLock(lock, blocking=True, site='flow_claim.claim')
330
+ try:
331
+ _claim_lock.acquire()
332
+ except LockError as exc:
333
+ print(f'认领互斥锁不可用:{exc} ⇒ 本轮放弃(无锁认领会把同一票派两遍)', file=sys.stderr)
334
+ return 2
335
+ try:
336
+ # ⚠️ 必须带超时(2026-09-22 事故):飞书半开连接会让 triage 永不返回,而认领锁一直被占
337
+ # ⇒ 定时器每轮都卡在这里、整条发票流程静默冻结 4h51m(日志停在那一刻,无任何报错)。
338
+ _tri_timeout = int(os.environ.get('ARGENT_TRIAGE_TIMEOUT') or 300)
339
+ _tri_cmd = [sys.executable, str(TRIAGE), '--flow', a.flow, '--json', '--ledger', str(ledger)]
340
+ # 离线夹具:原样透传(P4 离线冒烟 S3 的前置)
341
+ if a.cases_from_dir:
342
+ _tri_cmd += ['--cases-from-dir', a.cases_from_dir]
343
+ elif a.dry_run:
344
+ # 连**源层的 case 落盘**也不落进数据目录(A3 零副作用):换临时目录,
345
+ # 分诊仍从那里读回同一批 case ⇒ 判据路径不变,只是不留痕。
346
+ _tri_cmd += ['--out-dir', tempfile.mkdtemp(prefix='argent-claim-dry-cases-')]
347
+ try:
348
+ p = subprocess.run(_tri_cmd, capture_output=True, text=True, timeout=_tri_timeout)
349
+ except subprocess.TimeoutExpired:
350
+ print(f'triage 超时({_tri_timeout}s):读表未返回 ⇒ 本轮放弃(下一轮自愈)', file=sys.stderr)
351
+ return 2
352
+ if not p.stdout.strip().startswith('{'):
353
+ print(f'triage 失败:{(p.stderr or p.stdout).strip()[:200]}', file=sys.stderr); return 2
354
+ plan = json.loads(p.stdout)
355
+ now = time.time()
356
+ raw = list((plan.get('plan') or {}).get('to_dispatch', []))
357
+ # ── 账号硬校验(2026-09-23 用户口径 A)────────────────────────────
358
+ # lip 表的「店铺」字段**指定**该票用哪个账号(如"墨西哥-1店-子账号");实测匹配规则 =
359
+ # **逐字精确**(紫鸟清单与表内命名含相同的主/子后缀,无需归一化)。
360
+ # 不在清单里的票(实测:`巴西-1店` 5 张)**必须拒绝**:否则脚本只能猜账号 ⇒
361
+ # 货件建在 A 账号、后续步骤落到 B 账号 ⇒ 预约失败/孤儿货件。绝不回退默认账号。
362
+ _unknown = {}
363
+ # ⚠️ 只对**声明了店铺字段**的流程生效(2026-09-24 修,我自己引入的回归):
364
+ # 这条闸门原意是"lip 表的「店铺」字段逐字必须在本机紫鸟清单里"(用户口径 A,针对 FULL),
365
+ # 但它被无条件套到了**所有**流程上 —— 而 daily-report 的源根本没有店铺字段
366
+ # (`source.kind=cases-json`)⇒ 每个候选项的 store 都是空 ⇒ 全量落进 '(空)' 桶被拒 ⇒
367
+ # **日报单元从 09-23 起再也认领不出来**(今天实测:`账号校验:拒绝 1 张(店铺 '(空)' …)`)。
368
+ # 现在:① 未声明店铺字段的流程 ⇒ 整条闸门跳过(并留一行日志说明);
369
+ # ② 声明了店铺字段的流程里,**空店铺的行按 need_fields 处理**(分诊已归桶),
370
+ # 不再写 `store-unbound__(空)` 这种噪音台账。
371
+ _store_field = str((((cfg.get('source') or {}).get('fields') or {}).get('store')) or '').strip()
372
+ if os.environ.get('ARGENT_SKIP_STORE_CHECK') == '1':
373
+ pass
374
+ elif not _store_field:
375
+ print(' · 账号校验:本流程未声明店铺字段 ⇒ 跳过紫鸟店铺闸门(该闸门只对按店派发的流程生效)',
376
+ file=sys.stderr)
377
+ else:
378
+ _known = _ziniao_stores(write=not a.dry_run) # dry-run:只读缓存、不落盘(A3)
379
+ if _known:
380
+ keep = []
381
+ _empty = 0
382
+ for r in raw:
383
+ st = str(r.get('store') or '').strip()
384
+ if not st:
385
+ _empty += 1 # 空店铺 = 等运营补字段(分诊的 need_fields 桶会呈现)
386
+ continue
387
+ if st not in _known:
388
+ _unknown[st] = _unknown.get(st, 0) + 1
389
+ continue
390
+ keep.append(r)
391
+ if _empty:
392
+ print(f' · 账号校验:跳过 {_empty} 张(店铺为空 ⇒ 等运营补字段,不是"未授权")',
393
+ file=sys.stderr)
394
+ if _unknown:
395
+ for st, n in _unknown.items():
396
+ print(f' · 账号校验:拒绝 {n} 张(店铺 {st!r} 不在紫鸟清单里 ⇒ 无法确定用哪个账号)',
397
+ file=sys.stderr)
398
+ if a.dry_run:
399
+ # dry-run 零副作用:这条 blocked 是**真写台账**,预演时不许落
400
+ print(f' [dry-run] 不写 blocked 台账(caseId=store-unbound__{st})', file=sys.stderr)
401
+ continue
402
+ subprocess.run([sys.executable, str(HERE / 'flow_ledger.py'),
403
+ '--case-id', f'store-unbound__{st}', '--event', 'blocked',
404
+ '--by', 'flow-claim', '--detail',
405
+ f'店铺 {st!r} 未分配给本机紫鸟客户端:**不拾取该票**,等运营在紫鸟把该店铺分配给当前客户端后再跑(或核对 lip 表「店铺」字段写法)'],
406
+ capture_output=True, text=True)
407
+ raw = keep
408
+ # ── 店级限流退避(2026-09-23)──────────────────────────────
409
+ # 平台限流是按店铺代理 IP + 行为判定的;撞了 429/503 后**同店**立刻再派,只会继续撞。
410
+ # 做法:最近 throttleCooldownMin 分钟内被限流过的店铺 ⇒ 本轮整店跳过(并把原因写清楚)。
411
+ _throttle_min = int(cfg.get('concurrency', {}).get('throttleCooldownMin') or 30)
412
+ _store_of = {str(r.get('caseId')): str(r.get('store') or '')
413
+ for r in ((plan.get('plan') or {}).get('to_dispatch') or [])
414
+ + ((plan.get('plan') or {}).get('stale') or [])
415
+ + ((plan.get('plan') or {}).get('in_flight') or [])}
416
+ _hot = throttled_stores(ledger, now, _throttle_min, _store_of)
417
+ if _hot:
418
+ _before = len(raw)
419
+ raw = [r for r in raw if str(r.get('store') or '') not in _hot]
420
+ if len(raw) != _before:
421
+ print(f' · 店级限流退避:跳过 {_before - len(raw)} 张'
422
+ f'(店铺 {", ".join(sorted(_hot))} 近 {_throttle_min} 分钟被平台限流)', file=sys.stderr)
423
+ per_store = int(cfg['concurrency'].get('perStore') or 0)
424
+ # ── B3:按代理 IP 限并发(主/子账号同 IP 是旧口径的漏网口)──────────────
425
+ per_ip = int(getattr(a, 'per_ip', None) if getattr(a, 'per_ip', None) is not None
426
+ else (cfg['concurrency'].get('perIp') or 0))
427
+ store_ip = _ziniao_store_ips(write=not a.dry_run) if per_ip > 0 else {} # dry-run:不落缓存
428
+ if per_ip > 0 and not store_ip:
429
+ print(' · 按 IP 限并发:取不到店铺 IP 清单 ⇒ 本轮所有票归入同一「未知」桶(保守少派一次)',
430
+ file=sys.stderr)
431
+ _live = live_leases(ledger, now)
432
+ picked = select_candidates(raw, _live, max(0, max_n), per_store, batch_n, per_ip, store_ip)
433
+ if per_ip > 0 and len(picked) < len(raw):
434
+ # 说清楚"为什么少派"(否则看起来像分诊漏票)
435
+ _ips = {}
436
+ for r in raw:
437
+ st = str(r.get('store') or '')
438
+ _ips.setdefault(store_ip.get(st) or '__unknown__', []).append(st)
439
+ _busy_ip = [ip for ip, sts in _ips.items() if len(sts) > 1]
440
+ if _busy_ip:
441
+ print(f' · 按 IP 限并发(perIp={per_ip}):{len(_busy_ip)} 个 IP 上有多店共用'
442
+ f'({"; ".join(sorted(_busy_ip))[:120]})⇒ 同 IP 不并行', file=sys.stderr)
443
+ cands = [r['caseId'] for r in picked]
444
+ if len(picked) < len(raw):
445
+ if batch_n > 1:
446
+ print(f' · 一店一批(batchPerStore={batch_n}):候选 {len(raw)} 张 → 本轮认领 {len(picked)} 张'
447
+ f'(同店上一批仍在跑 ⇒ 整店跳过;同批上限 {batch_n} 张)', file=sys.stderr)
448
+ elif per_store > 0:
449
+ busy = {}
450
+ for r in raw:
451
+ st = str(r.get('store') or '')
452
+ busy[st] = busy.get(st, 0) + 1
453
+ print(f' · 按店限并发(perStore={per_store}):候选 {len(raw)} 张 → 本轮认领 {len(picked)} 张'
454
+ f'(同店已在飞或本轮已选满的不再认领)', file=sys.stderr)
455
+ if not cands:
456
+ # 带上"为什么没有票"(源清单说明:已推送/未到点/窗口已过/等运营补字段)——
457
+ # 2026-09-18 用户实测:只有"无票可认领"时,日报静默停摆一整天无人察觉。
458
+ notes = [str(x) for x in ((plan.get('notes') or []))][:6]
459
+ summ = plan.get('summary') or {}
460
+ if not notes and summ:
461
+ parts = [f"可派发 {summ.get('to_dispatch', 0)}", f"待放行 {summ.get('await_release', 0)}",
462
+ f"等人补字段 {summ.get('need_fields', 0)}", f"在飞 {summ.get('in_flight', 0)}",
463
+ f"停滞 {summ.get('stale', 0)}", f"需人工 {summ.get('needs_human', 0)}"]
464
+ notes = ['本轮分诊:' + '|'.join(parts)]
465
+ if a.json:
466
+ # 无票分支也回显**实际生效**的条件(max/leaseMin 可能来自流程配置、非命令行)——
467
+ # verb 适配层要按 A2 把生效值如实回显,宿主才分得清"真没票"与"被谁拦掉了"。
468
+ print(json.dumps({'claimed': [], 'flow': a.flow, 'reason': ';'.join(notes),
469
+ 'max': max_n, 'leaseMin': lease_min, 'dryRun': bool(a.dry_run),
470
+ 'lock': str(lock), 'ledger': str(ledger),
471
+ 'casesFromDir': a.cases_from_dir or None}))
472
+ else:
473
+ print(' 无票可认领' + (f"({';'.join(notes)})" if notes else ''))
474
+ return 3
475
+ if a.dry_run:
476
+ if a.json: print(json.dumps({'claimed': cands, 'dryRun': True, 'flow': a.flow,
477
+ 'max': max_n, 'leaseMin': lease_min, 'lock': str(lock),
478
+ 'ledger': str(ledger)}))
479
+ else: print(f' [dry-run] 将认领 {len(cands)} 张:{", ".join(cands)}')
480
+ return 0
481
+ now = int(time.time())
482
+ for cid in cands:
483
+ _cand = next((r for r in picked if r.get('caseId') == cid), {})
484
+ _st = str(_cand.get('store') or '')
485
+ append(ledger, planned_record(cid, _st, now, now + lease_min * 60, _cand))
486
+ if a.json:
487
+ print(json.dumps({'claimed': cands, 'flow': a.flow, 'max': max_n,
488
+ 'leaseMin': lease_min, 'lock': str(lock)}))
489
+ else:
490
+ print(f' ✅ 已认领 {len(cands)} 张(租约 {lease_min} 分钟,锁 {lock.name}):{", ".join(cands)}')
491
+ return 0
492
+ finally:
493
+ _claim_lock.release()
494
+
495
+
496
+ if __name__ == '__main__':
497
+ raise SystemExit(main())