@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.
Files changed (305) hide show
  1. package/MANIFEST.json +307 -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 +575 -0
  55. package/engine/scripts/billing-mode-daily.sh +12 -0
  56. package/engine/scripts/build-engine-package.mjs +123 -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 +377 -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/config-explain.mjs +278 -0
  81. package/engine/scripts/configure-provider.rb +29 -0
  82. package/engine/scripts/customer-config-migrate.mjs +179 -0
  83. package/engine/scripts/customer-preflight.py +892 -0
  84. package/engine/scripts/customer-readiness.sh +134 -0
  85. package/engine/scripts/daily-report-inbox.py +242 -0
  86. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  87. package/engine/scripts/daily-report.mjs +1310 -0
  88. package/engine/scripts/daily-report.sh +46 -0
  89. package/engine/scripts/data/lip-product-dict.json +306 -0
  90. package/engine/scripts/deliver-customer.mjs +932 -0
  91. package/engine/scripts/delivery-faces.txt +31 -0
  92. package/engine/scripts/desktop-trial.sh +146 -0
  93. package/engine/scripts/dev-window.sh +260 -0
  94. package/engine/scripts/drill-clean-machine.sh +315 -0
  95. package/engine/scripts/dsh-api-client.mjs +215 -0
  96. package/engine/scripts/dsh-backup-scope.sh +83 -0
  97. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  98. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  99. package/engine/scripts/dsh-plane.mjs +153 -0
  100. package/engine/scripts/dsh-portforward.py +223 -0
  101. package/engine/scripts/dsh-preflight.mjs +179 -0
  102. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  103. package/engine/scripts/dsh-release-watch.mjs +458 -0
  104. package/engine/scripts/dsh-remote-check.sh +99 -0
  105. package/engine/scripts/dsh-restart.sh +179 -0
  106. package/engine/scripts/dsh-restore.sh +111 -0
  107. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  108. package/engine/scripts/dsh-snapshot.mjs +116 -0
  109. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  110. package/engine/scripts/dsh-upgrade.mjs +612 -0
  111. package/engine/scripts/dsh-url.sh +86 -0
  112. package/engine/scripts/dsh-web-launch.mjs +155 -0
  113. package/engine/scripts/ensure-store-windows.py +509 -0
  114. package/engine/scripts/entitlements.py +458 -0
  115. package/engine/scripts/env_probe.py +411 -0
  116. package/engine/scripts/feishu-card.mjs +252 -0
  117. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  118. package/engine/scripts/flow-auto-retry.py +454 -0
  119. package/engine/scripts/flow-requirements.json +720 -0
  120. package/engine/scripts/flow-scaffold.py +275 -0
  121. package/engine/scripts/flow-timers-plist.sh +86 -0
  122. package/engine/scripts/flow-timers.sh +182 -0
  123. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  124. package/engine/scripts/flow-trigger.sh +571 -0
  125. package/engine/scripts/flow_billing.py +354 -0
  126. package/engine/scripts/flow_capability_stats.py +314 -0
  127. package/engine/scripts/flow_case.py +194 -0
  128. package/engine/scripts/flow_claim.py +497 -0
  129. package/engine/scripts/flow_config.py +1024 -0
  130. package/engine/scripts/flow_failure.py +588 -0
  131. package/engine/scripts/flow_learn.py +105 -0
  132. package/engine/scripts/flow_ledger.py +235 -0
  133. package/engine/scripts/flow_policy.py +238 -0
  134. package/engine/scripts/flow_preflight.py +339 -0
  135. package/engine/scripts/flow_registry.py +341 -0
  136. package/engine/scripts/flow_retry.py +304 -0
  137. package/engine/scripts/flow_return_to_ops.py +191 -0
  138. package/engine/scripts/flow_source.py +315 -0
  139. package/engine/scripts/flow_sources/__init__.py +53 -0
  140. package/engine/scripts/flow_sources/cases_json.py +106 -0
  141. package/engine/scripts/flow_sources/common.py +428 -0
  142. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  143. package/engine/scripts/flow_stale.py +185 -0
  144. package/engine/scripts/flow_status_reconcile.py +344 -0
  145. package/engine/scripts/flow_triage.py +849 -0
  146. package/engine/scripts/flow_write_verify.py +496 -0
  147. package/engine/scripts/fulfillment.js +586 -0
  148. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  149. package/engine/scripts/gen-preset-rows.mjs +259 -0
  150. package/engine/scripts/init-ml.sh +2275 -0
  151. package/engine/scripts/install-internal-role.sh +190 -0
  152. package/engine/scripts/install-license.mjs +641 -0
  153. package/engine/scripts/install-one-shot.mjs +665 -0
  154. package/engine/scripts/integration_contract.py +220 -0
  155. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  156. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  157. package/engine/scripts/invoice-mapping.example.json +40 -0
  158. package/engine/scripts/invoice-table.sh +194 -0
  159. package/engine/scripts/invoice_carrier_notice.py +279 -0
  160. package/engine/scripts/invoice_fail_closeout.py +297 -0
  161. package/engine/scripts/invoice_lip_runner.py +1508 -0
  162. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  163. package/engine/scripts/invoice_ready_audit.py +191 -0
  164. package/engine/scripts/invoice_runner.py +675 -0
  165. package/engine/scripts/invoice_stageb.py +516 -0
  166. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  167. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  168. package/engine/scripts/lease.py +377 -0
  169. package/engine/scripts/lib/agent-install.mjs +649 -0
  170. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  171. package/engine/scripts/lib/argent_datadir.py +262 -0
  172. package/engine/scripts/lib/argent_filelock.py +507 -0
  173. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  174. package/engine/scripts/lib/cdp_ports.py +197 -0
  175. package/engine/scripts/lib/customer-env.mjs +102 -0
  176. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  177. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  178. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  179. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  180. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  181. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  182. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  183. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  184. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  185. package/engine/scripts/lib/patch-compose.mjs +241 -0
  186. package/engine/scripts/lib/patch-target.mjs +35 -0
  187. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  188. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  189. package/engine/scripts/lib/preset-compat.mjs +127 -0
  190. package/engine/scripts/lib/preset-install.mjs +575 -0
  191. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  192. package/engine/scripts/lib/preset-sync.mjs +59 -0
  193. package/engine/scripts/lib/profile-retire.mjs +97 -0
  194. package/engine/scripts/lib/python-runtime.mjs +190 -0
  195. package/engine/scripts/lib/registry.mjs +72 -0
  196. package/engine/scripts/lib/repo-root.sh +56 -0
  197. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  198. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  199. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  200. package/engine/scripts/lib/settings-panels.mjs +816 -0
  201. package/engine/scripts/lib/stdio_utf8.py +86 -0
  202. package/engine/scripts/lib/wake-auth.mjs +392 -0
  203. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  204. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  205. package/engine/scripts/license-e2e-drill.mjs +426 -0
  206. package/engine/scripts/license-gate.mjs +245 -0
  207. package/engine/scripts/lip_backlog.py +171 -0
  208. package/engine/scripts/lip_table.py +185 -0
  209. package/engine/scripts/local-usage.mjs +214 -0
  210. package/engine/scripts/migrate-providers.mjs +127 -0
  211. package/engine/scripts/migrate-skills.sh +65 -0
  212. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  213. package/engine/scripts/ml-store-ids.json +26 -0
  214. package/engine/scripts/ml_artifact_attribution.py +333 -0
  215. package/engine/scripts/ml_capacity_probe.py +1158 -0
  216. package/engine/scripts/ml_hub_status.py +1556 -0
  217. package/engine/scripts/ml_schema_check.py +115 -0
  218. package/engine/scripts/ml_store_ids.py +139 -0
  219. package/engine/scripts/npm-publish-argent.mjs +499 -0
  220. package/engine/scripts/npm-publish-argent.sh +20 -0
  221. package/engine/scripts/orchestrator.py +13811 -0
  222. package/engine/scripts/pack-plugins.mjs +140 -0
  223. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  224. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  225. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  226. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  227. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  228. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  229. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  230. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  231. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  232. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  233. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  234. package/engine/scripts/plan-role-rename.mjs +125 -0
  235. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  236. package/engine/scripts/plugin-composition.json +15 -0
  237. package/engine/scripts/poc-mcp-host.mjs +455 -0
  238. package/engine/scripts/poll-fulfillment.sh +293 -0
  239. package/engine/scripts/poll-invoice.sh +74 -0
  240. package/engine/scripts/profile-doctor.mjs +686 -0
  241. package/engine/scripts/provider-select.sh +70 -0
  242. package/engine/scripts/provider-switch.sh +146 -0
  243. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  244. package/engine/scripts/python-runtime.sh +78 -0
  245. package/engine/scripts/query_runner.py +310 -0
  246. package/engine/scripts/release-argent.sh +574 -0
  247. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  248. package/engine/scripts/run-tests.sh +256 -0
  249. package/engine/scripts/self-sign-license.mjs +179 -0
  250. package/engine/scripts/session-health-scan.mjs +63 -0
  251. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  252. package/engine/scripts/setup.sh +22 -0
  253. package/engine/scripts/skill-install.mjs +629 -0
  254. package/engine/scripts/sku_translate.py +148 -0
  255. package/engine/scripts/sync-plugins.sh +90 -0
  256. package/engine/scripts/sync-presets.sh +27 -0
  257. package/engine/scripts/sync-scripts.sh +445 -0
  258. package/engine/scripts/telemetry.py +873 -0
  259. package/engine/scripts/ui-copy-count.mjs +220 -0
  260. package/engine/scripts/usage-anchor.sh +136 -0
  261. package/engine/scripts/usage_report.py +531 -0
  262. package/engine/scripts/validate-preset-schema.mjs +208 -0
  263. package/engine/scripts/vertical-init.mjs +323 -0
  264. package/engine/scripts/vertical-lint.mjs +482 -0
  265. package/engine/scripts/wenyao-browser.sh +73 -0
  266. package/engine/scripts/wenyao-fixture-export.py +116 -0
  267. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  268. package/engine/scripts/wenyao-login.sh +34 -0
  269. package/engine/scripts/wenyao-template-map.json +292 -0
  270. package/engine/scripts/wenyao_attach.py +131 -0
  271. package/engine/scripts/wenyao_download_template.py +144 -0
  272. package/engine/scripts/wenyao_fill_template.py +568 -0
  273. package/engine/scripts/wenyao_login.py +145 -0
  274. package/engine/scripts/wenyao_portal.py +633 -0
  275. package/engine/scripts/wenyao_recon.py +230 -0
  276. package/engine/scripts/wenyao_run.py +503 -0
  277. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  278. package/engine/scripts/wenyao_skill_contract.py +156 -0
  279. package/engine/scripts/wenyao_template_fetch.py +175 -0
  280. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  281. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  282. package/engine/scripts/ziniao-keepalive.sh +216 -0
  283. package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
  284. package/engine/scripts/ziniao-windows.sh +153 -0
  285. package/engine/version.mjs +1 -0
  286. package/engine/verticals/README.md +103 -0
  287. package/engine/verticals/_engine-words.baseline.txt +237 -0
  288. package/engine/verticals/_engine-words.json +19 -0
  289. package/engine/verticals/mercadolibre/README.md +43 -0
  290. package/engine/verticals/mercadolibre/config.json +709 -0
  291. package/engine/verticals/mercadolibre/flows.json +706 -0
  292. package/engine/verticals/mercadolibre/manifest.json +538 -0
  293. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  294. package/engine/verticals/mercadolibre/preflight.py +143 -0
  295. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  296. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  297. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  298. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  299. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  300. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  301. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  302. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  303. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  304. package/package.json +21 -4
  305. package/README.md +0 -3
@@ -0,0 +1,507 @@
1
+ #!/usr/bin/env python3
2
+ """argent_filelock —— **跨平台排他文件锁的唯一真源**(POSIX `fcntl.flock` / Windows `msvcrt.locking`)。
3
+
4
+ ## 为什么有它(2026-10-09,Windows 可移植性)
5
+ 引擎里的"加锁"此前各写各的,两种坏法:
6
+ · 三个脚本**顶层** `import fcntl` ⇒ Windows 上 `ModuleNotFoundError` ⇒
7
+ 认领 / 台账 / 遥测**整个不可用**(与定时方案无关,换任何调度都绕不过);
8
+ · 编排器里 10 处 `import fcntl` 在**函数内 + `except: pass`** ⇒ Windows 上锁**静默变 no-op**
9
+ (并发下重复执行同一件工作),而且在 macOS 上把真实错误一起吃掉。
10
+ ⇒ 收敛成这一个文件:**同一套 API、两种后端、零静默降级**。
11
+
12
+ ## 为什么叫 `argent_filelock`(不是 `filelock`)
13
+ PyPI 上有一个**同名且极常见**的包 `filelock`(很多工具链的传递依赖),且它在 Windows 上也有
14
+ 一个 `WindowsFileLock` 类。模块名撞车时,**谁的路径在前谁赢** —— 一旦 `scripts/lib/` 不在
15
+ `sys.path` 最前(诊断脚本、半装机器、`python -c` 探针),`import filelock` 会静默拿到那个第三方包
16
+ (其 `FileLock` 没有 `try_acquire`/`best_effort`,行为与语义都不是本引擎的)。
17
+ 2026-10-09 在真机(Windows)上**实测命中**该撞名(探针拿到的就是第三方类的 `AttributeError`)
18
+ ⇒ 按仓库既有惯例(`argent_datadir.py` 同理)加前缀,让"导入到哪一个"不再有歧义。
19
+
20
+ ## API
21
+ lk = FileLock(path_or_file, blocking=True) # 阻塞拿锁(等到拿到为止)
22
+ lk = FileLock(path_or_file, timeout=0.5) # 最多等 0.5s,拿不到 ⇒ acquire() 返回 False
23
+ ok = lk.try_acquire() # 非阻塞试一次;拿不到 ⇒ False(**绝不**抛"忙")
24
+ ok = lk.acquire(timeout=2.0) # 同上,可带超时
25
+ lk.release() # 解锁(路径模式:连 fd 一起关)
26
+ with FileLock(path, blocking=True): ... # 拿不到 ⇒ 抛 LockTimeout / LockError
27
+ with best_effort(f, site="…") as lk: ... # 策略(b):拿不到 ⇒ 响亮告警后**无锁继续**
28
+ `acquire()` / `try_acquire()` 的三种结果**互相区分**(这是本文件的核心契约):
29
+ · `True` = 拿到了;
30
+ · `False` = **没拿到**(别人持有 / 等超时)—— 明确的返回值,不是异常;
31
+ · 抛异常 = **锁本身不可用**(没有后端、锁文件建不出来、后端报非"忙"的错)⇒ `LockError`。
32
+ ⛔ **"锁不可用"绝不等于"拿到了锁"**:调用方必须显式选策略(见下),不许 `except: pass`。
33
+
34
+ ## 语义(**与 POSIX 现状逐字等价** ⇒ 迁移后 macOS 行为零变化)
35
+ · **排他 + 阻塞可配**:`blocking=True` 时 POSIX 直接调 `flock(fd, LOCK_EX)` —— **与旧代码同一次
36
+ 系统调用**(内核阻塞,不轮询);非阻塞用 `LOCK_EX | LOCK_NB`(也与旧代码同)。
37
+ · **不缓存、不共享 fd**:每次 `acquire()` 都**重新 `open()`** 再上锁 ⇒ 与 `fcntl.flock` 的
38
+ "锁挂在打开文件描述(open file description)上"完全一致:**同一进程里两次 open 也互斥**
39
+ (既有回归 `scripts/tests/store-executor-lock.test.py` 正是这么断言的)。
40
+ · **同一实例重复 `acquire()` 幂等**(`_held` 短路,不重复系统调用)—— 与"同一 fd 上重复
41
+ `flock(LOCK_EX)` 成功"一致。
42
+ · **锁的生命周期 = fd 的生命周期**:`release()` / 关文件 / 进程退出都会释放(两种后端都如此)
43
+ ⇒ 不留"死锁等人清"。
44
+ · **锁文件路径约定不变**:调用方给什么就用什么 —— ① **专用锁文件**(如
45
+ `$ARGENT_LOCK_DIR/<name>.lock`、数据目录里的 `flow-claim-<flow>.lock`):传路径;
46
+ ② **就地锁数据文件**(台账 / outbox 这类"锁自己"的):传**已打开的文件对象**,锁的是同一个 fd。
47
+ 两种都不新造文件。
48
+ 路径模式下本对象自己 `open()`,**编码固定 UTF-8 + LF**(锁文件内容是诊断用的持有者 JSON,
49
+ 非数据契约;macOS 上 locale 本就是 UTF-8 ⇒ 与旧行为逐字节一致,Windows 中文 locale 下不再
50
+ 因 GBK 写中文而炸)。
51
+
52
+ ## 后端差异(照实写,不粉饰)
53
+ | 维度 | POSIX(fcntl.flock) | Windows(msvcrt.locking) |
54
+ |---|---|---|
55
+ | 阻塞等待 | 内核阻塞,0 轮询 | `LK_NBLCK` + 轮询(`LK_LOCK` 重试约 10s 就放弃 ⇒ **不能**当无限等待用) |
56
+ | 加锁区间 | 整个文件(flock 的粒度就是文件) | **第 2^40 字节起 1 字节**(区间长度恒为 1;`LK_UNLCK` 必须同区间 ⇒ 加/解锁前都 `lseek` 到该偏移,前后恢复原位置)。位置**故意在文件内容之外**:Windows 锁是强制式的,锁第 0 字节会让别的进程**读不了整个文件**(台账/outbox 的读者会 `PermissionError`,2026-10-09 真机实测) |
57
+ | 锁的性质 | 劝告式(advisory) | **强制式(mandatory)**:别人**读**被锁的字节会报错 ⇒ 读锁文件里的持有者要跳过第 0 字节(见 `read_lock_holder`) |
58
+ | 0 字节文件 | 可锁 | 可锁(`LockFile` 允许锁到 EOF 之外)⇒ **不写填充字节**,不污染被就地锁的数据文件 |
59
+
60
+ ## 降级策略(本文件只提供**手段**;策略由调用方**显式**选,判据 = "丢了锁会不会让两方跑同一件工作")
61
+ · **(a) fail-closed**:直接用 `FileLock` 并自己处理 `LockError`(拿不到 ⇒ **明确失败**,绝不无锁继续)。
62
+ 用于**互斥**语义的锁(认领临界区、同店执行者、同记录、同店步骤互斥、全局端口差集)。
63
+ · **(b) 响亮降级**:用 `best_effort(...)`(拿不到 ⇒ **每次计数 + 每进程每 site 喊一次** stderr,然后无锁继续)。
64
+ 只用于"追加 / 审计写"这类**自身约定就是"不阻断主流程"**的场景。
65
+ · `ARGENT_LOCK_STRICT=1` ⇒ 把 (b) **升级**成 (a)(CI / 生产可要求"绝不允许降级")。
66
+ · ⛔ 任何情况下都**不存在**"没拿到锁但一声不吭"的路径(旧实现的 `except: pass` 已全部拆除)。
67
+
68
+ ## 自检
69
+ `python3 scripts/lib/argent_filelock.py --probe <锁文件>`(非阻塞试一次;0=拿到 / 3=被别人持有 / 2=不可用)
70
+ `python3 scripts/lib/argent_filelock.py --hold <锁文件> <秒>`(持有若干秒,供**两进程抢锁**验证;
71
+ `--backend` / `--version` 可打印后端与是否为 Windows 分支)。完整回归见
72
+ `scripts/tests/filelock.test.py`。
73
+ """
74
+ from __future__ import annotations
75
+
76
+ import errno
77
+ import os
78
+ import sys
79
+ import time
80
+ from contextlib import contextmanager
81
+ from typing import Any, Dict, Iterator, Optional, Set, Union
82
+
83
+ __all__ = [
84
+ "FileLock", "LockError", "LockBackendUnavailable", "LockTimeout",
85
+ "backend_name", "is_windows", "read_lock_holder", "best_effort",
86
+ "warn_degraded", "degraded_counts", "reset_degraded", "strict_mode",
87
+ ]
88
+
89
+ # ── 后端探测(**导入期绝不抛**)──────────────────────────────────────────────
90
+ # 缺后端要在"上锁那一刻"以**明确异常**暴露,而不是让 `import` 整个炸掉 ——
91
+ # 后者正是本次要修的缺陷形态(顶层 import fcntl ⇒ Windows 上三个脚本全不可用)。
92
+ _fcntl: Any = None
93
+ _msvcrt: Any = None
94
+ if os.name == "nt":
95
+ try:
96
+ import msvcrt as _msvcrt
97
+ except Exception: # noqa: BLE001
98
+ _msvcrt = None
99
+ else:
100
+ try:
101
+ import fcntl as _fcntl
102
+ except Exception: # noqa: BLE001
103
+ _fcntl = None
104
+ if _fcntl is None:
105
+ # POSIX 上正常没有 msvcrt;这一支留给两处:① 夹具注入假 msvcrt 走 Windows 分支的回归;
106
+ # ② "fcntl 被裁掉的半坏环境"仍有一线可用后端(拿不到才明确失败)。
107
+ try:
108
+ import msvcrt as _msvcrt
109
+ except Exception: # noqa: BLE001
110
+ _msvcrt = None
111
+
112
+ _BACKEND: Optional[str] = ("msvcrt" if (os.name == "nt" and _msvcrt is not None)
113
+ else "fcntl" if _fcntl is not None
114
+ else "msvcrt" if _msvcrt is not None else None)
115
+ _POLL_SEC = 0.05 # 只用于"有限超时"与 Windows 分支(POSIX 阻塞路径零轮询)
116
+ _LOCK_BYTES = 1 # Windows:锁区间**固定 1 字节**(跨进程可见)
117
+ # Windows:锁区间放在**文件内容之外**的第 2^40 字节起 1 字节(`LockFile` 允许锁到 EOF 之外,
118
+ # 且**不分配、不改文件大小**)。为什么不锁第 0 字节(2026-10-09 真机实测):Windows 的字节区间锁是
119
+ # **强制式**的 ⇒ 锁住第 0 字节会让**别的进程连整个文件都读不了**(`flow_ledger.read()` 当场
120
+ # `PermissionError: [Errno 13]`)—— 而台账/outbox 是"就地锁数据文件"的用法,读者遍地都是
121
+ # (分诊/工作台/遥测 `--status`)。挪到内容之外后:锁照样互斥,**读者一个都不受影响**。
122
+ _WIN_LOCK_OFFSET = 1 << 40
123
+ _BUSY_ERRNOS = frozenset(
124
+ int(e) for e in (getattr(errno, n, None) for n in
125
+ ("EACCES", "EAGAIN", "EWOULDBLOCK", "EDEADLK", "EDEADLOCK"))
126
+ if isinstance(e, int))
127
+ _BUSY_WINERRORS = frozenset((33, 36)) # ERROR_LOCK_VIOLATION / ERROR_SHARING_BUFFER_EXCEEDED
128
+
129
+
130
+ class LockError(Exception):
131
+ """锁**不可用**(≠"被别人占用"):无后端、锁文件打不开、后端报了非"忙"的错。"""
132
+
133
+
134
+ class LockBackendUnavailable(LockError):
135
+ """本机没有任何可用后端(既无 `fcntl` 也无 `msvcrt`)⇒ 无法保证互斥。"""
136
+
137
+
138
+ class LockTimeout(LockError):
139
+ """上下文管理器在超时内没拿到锁(`acquire` / `try_acquire` 走返回值,不抛这个)。"""
140
+
141
+
142
+ def is_windows() -> bool:
143
+ """当前生效的后端是不是 Windows 分支(夹具注入 msvcrt 时为 True)。"""
144
+ return _BACKEND == "msvcrt"
145
+
146
+
147
+ def backend_name() -> Optional[str]:
148
+ """当前生效的后端名:`'fcntl'` / `'msvcrt'` / `None`(无后端)。"""
149
+ return _BACKEND
150
+
151
+
152
+ def _is_busy(exc: BaseException) -> bool:
153
+ """"被别人持有"的判定 —— 与"后端坏了"分开,后者必须冒出来。"""
154
+ if getattr(exc, "errno", None) in _BUSY_ERRNOS:
155
+ return True
156
+ return getattr(exc, "winerror", None) in _BUSY_WINERRORS
157
+
158
+
159
+ # ── 降级告警(响亮 + 去重 + 计数;`ARGENT_LOCK_STRICT=1` 升级为失败)──────────────
160
+ _DEGRADED: Dict[str, int] = {}
161
+ _WARNED: Set[str] = set()
162
+
163
+
164
+ def strict_mode() -> bool:
165
+ """`ARGENT_LOCK_STRICT=1` ⇒ 拒绝任何降级(把策略 (b) 升级成 (a))。"""
166
+ return str(os.environ.get("ARGENT_LOCK_STRICT") or "").strip().lower() in ("1", "true", "yes", "on")
167
+
168
+
169
+ def _warn(msg: str, dedup: Optional[str] = None) -> None:
170
+ if dedup is not None:
171
+ if dedup in _WARNED:
172
+ return
173
+ _WARNED.add(dedup)
174
+ try:
175
+ print(f"[filelock] {msg}", file=sys.stderr)
176
+ except Exception: # noqa: BLE001
177
+ pass # 连 stderr 都写不了时也只能算了
178
+
179
+
180
+ def warn_degraded(site: str, exc: Optional[BaseException] = None, note: str = "") -> None:
181
+ """**响亮但去重**的降级告警:同一 site 每进程喊一次,**每次都计数**(`degraded_counts()`)。
182
+
183
+ `ARGENT_LOCK_STRICT=1` ⇒ 改为抛 `LockError`(调用方由此得到 fail-closed 语义)。
184
+ """
185
+ key = "%s:%s" % (site or "?", type(exc).__name__ if exc is not None else "-")
186
+ _DEGRADED[key] = _DEGRADED.get(key, 0) + 1
187
+ msg = ("⚠️ 锁不可用 ⇒ **降级为无锁** [%s](第 %d 次)%s%s"
188
+ % (site or "?", _DEGRADED[key], (":" + note) if note else "",
189
+ (":" + str(exc)) if exc is not None else ""))
190
+ if strict_mode():
191
+ raise LockError(msg + "(ARGENT_LOCK_STRICT=1 ⇒ 拒绝降级)")
192
+ _warn(msg, dedup=key)
193
+
194
+
195
+ def degraded_counts() -> Dict[str, int]:
196
+ """各 site 的降级次数(测试与排障用)。"""
197
+ return dict(_DEGRADED)
198
+
199
+
200
+ def reset_degraded() -> None:
201
+ _DEGRADED.clear()
202
+ _WARNED.clear()
203
+
204
+
205
+ # ── Windows 分支的小工具 ────────────────────────────────────────────────────
206
+ def _fd_tell(fd: int) -> Optional[int]:
207
+ try:
208
+ return os.lseek(fd, 0, os.SEEK_CUR)
209
+ except OSError:
210
+ return None
211
+
212
+
213
+ def _fd_restore(fd: int, pos: Optional[int]) -> None:
214
+ if pos is None:
215
+ return
216
+ try:
217
+ os.lseek(fd, pos, os.SEEK_SET)
218
+ except OSError:
219
+ pass
220
+
221
+
222
+ def read_lock_holder(path: Union[str, "os.PathLike[str]"]) -> str:
223
+ """读锁文件里的"持有者"文本(诊断用;**任何失败都返回空串**,绝不抛)。
224
+
225
+ 锁区间在**文件内容之外**(`_WIN_LOCK_OFFSET`)⇒ 两种后端下这个读都畅通:
226
+ POSIX 的 `flock` 本来就劝告式(不挡读),Windows 锁的也不是内容区。
227
+ ⛔ 若将来有人把 `_WIN_LOCK_OFFSET` 改回 0,这个函数会立刻在 Windows 上失效(强制锁挡读)——
228
+ 真机实测见常量处的说明。
229
+ """
230
+ try:
231
+ with open(os.fspath(path), "r", encoding="utf-8", errors="replace") as f:
232
+ return f.read().strip()
233
+ except OSError:
234
+ return ""
235
+
236
+
237
+ # ── 锁本体 ──────────────────────────────────────────────────────────────────
238
+ class FileLock:
239
+ """排他文件锁(POSIX `flock` / Windows `msvcrt.locking`)。
240
+
241
+ `target` 可以是**路径**(本对象负责 open/close)或**已打开的文件对象**(借用其 fd,不关它)。
242
+ `blocking` 只在 `timeout is None` 时有意义(True=等到拿到为止,False=只试一次);
243
+ 给了 `timeout` 就统一按"最多等这么久"处理(与 `blocking` 无关)。
244
+ """
245
+
246
+ def __init__(self, target: Any, *, blocking: bool = False, timeout: Optional[float] = None,
247
+ mode: str = "a+", encoding: Optional[str] = "utf-8", site: str = "") -> None:
248
+ self._is_path = isinstance(target, (str, bytes, os.PathLike))
249
+ self._target = target
250
+ self._mode = mode
251
+ self._encoding = encoding
252
+ self._borrowed = None if self._is_path else target
253
+ self.blocking = bool(blocking)
254
+ self.timeout = timeout
255
+ self.site = site or ""
256
+ self.path = os.fspath(target) if self._is_path else str(getattr(target, "name", "") or "")
257
+ self._fp = None
258
+ self._owned = False
259
+ self._held = False
260
+ self.last_error: Optional[BaseException] = None
261
+
262
+ # ── 只读视图 ──
263
+ @property
264
+ def held(self) -> bool:
265
+ return self._held
266
+
267
+ @property
268
+ def fp(self) -> Any:
269
+ """底层文件对象(路径模式下 acquire 前为 None)——持有者信息等诊断写入口。"""
270
+ return self._fp if self._is_path else self._borrowed
271
+
272
+ def fileno(self) -> int:
273
+ fp = self.fp
274
+ try:
275
+ return int(fp.fileno())
276
+ except Exception: # noqa: BLE001
277
+ return -1
278
+
279
+ def __repr__(self) -> str: # pragma: no cover - 诊断用
280
+ return "<FileLock %s backend=%s held=%s site=%s>" % (
281
+ self.path or "-", _BACKEND, self._held, self.site or "-")
282
+
283
+ # ── 打开 / 关闭 ──
284
+ def _ensure_open(self) -> int:
285
+ if not self._is_path:
286
+ fp = self._borrowed
287
+ if fp is None or getattr(fp, "closed", False):
288
+ raise LockError("借用的文件对象已关闭,无法上锁:%s" % (self.path or "-"))
289
+ try:
290
+ return int(fp.fileno())
291
+ except Exception as exc: # noqa: BLE001
292
+ raise LockError("借用的对象没有可用 fd:%s" % exc) from exc
293
+ if self._fp is None:
294
+ try:
295
+ # newline="\n" ⇒ 与 POSIX 现状逐字节一致(Windows 上不会因 os.linesep 变 CRLF)
296
+ self._fp = open(self._target, self._mode, encoding=self._encoding, newline="\n")
297
+ except OSError as exc:
298
+ raise LockError("打不开锁文件 %s:%s" % (self.path or "-", exc)) from exc
299
+ self._owned = True
300
+ return int(self._fp.fileno())
301
+
302
+ def close(self) -> None:
303
+ """关掉**本对象自己打开**的 fd(借用模式只解锁,绝不关别人的文件)。"""
304
+ if self._owned and self._fp is not None:
305
+ try:
306
+ self._fp.close()
307
+ except Exception as exc: # noqa: BLE001
308
+ self.last_error = exc
309
+ _warn("关闭锁文件失败(%s):%s" % (self.path or "-", exc),
310
+ dedup="close:%s" % self.path)
311
+ self._fp = None
312
+ self._owned = False
313
+
314
+ # ── 加锁 / 解锁 ──
315
+ def _try_once(self, fd: int) -> bool:
316
+ if _BACKEND == "fcntl":
317
+ try:
318
+ _fcntl.flock(fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
319
+ return True
320
+ except OSError as exc:
321
+ if _is_busy(exc):
322
+ return False
323
+ raise LockError("fcntl.flock(LOCK_EX|LOCK_NB) 失败(%s):%s" % (self.path or "-", exc)) from exc
324
+ pos = _fd_tell(fd)
325
+ try:
326
+ os.lseek(fd, _WIN_LOCK_OFFSET, os.SEEK_SET) # 内容之外(见常量处的实测说明)
327
+ try:
328
+ _msvcrt.locking(fd, _msvcrt.LK_NBLCK, _LOCK_BYTES) # 非阻塞;区间固定 1 字节
329
+ return True
330
+ except OSError as exc:
331
+ if _is_busy(exc):
332
+ return False
333
+ raise LockError("msvcrt.locking(LK_NBLCK) 失败(%s):%s" % (self.path or "-", exc)) from exc
334
+ finally:
335
+ _fd_restore(fd, pos)
336
+
337
+ def _acquire_forever(self, fd: int) -> bool:
338
+ """阻塞直到拿到(与旧实现同语义)。POSIX 走内核阻塞;Windows 只能轮询。"""
339
+ if _BACKEND == "fcntl":
340
+ try:
341
+ _fcntl.flock(fd, _fcntl.LOCK_EX) # ← 与旧代码**同一次**系统调用
342
+ return True
343
+ except OSError as exc:
344
+ raise LockError("fcntl.flock(LOCK_EX) 失败(%s):%s" % (self.path or "-", exc)) from exc
345
+ while True:
346
+ if self._try_once(fd):
347
+ return True
348
+ time.sleep(_POLL_SEC)
349
+
350
+ def _acquire_deadline(self, fd: int, wait: float) -> bool:
351
+ deadline = time.monotonic() + max(0.0, float(wait))
352
+ while True:
353
+ if self._try_once(fd):
354
+ return True
355
+ now = time.monotonic()
356
+ if now >= deadline:
357
+ return False
358
+ time.sleep(min(_POLL_SEC, max(0.0, deadline - now)))
359
+
360
+ def acquire(self, timeout: Optional[float] = None) -> bool:
361
+ """拿锁。返回 False = **没拿到**(被别人持有 / 等超时);抛 `LockError` = **锁不可用**。"""
362
+ if self._held:
363
+ return True # 同实例重复拿 = 幂等(同 fd 重复 flock)
364
+ if _BACKEND is None:
365
+ raise LockBackendUnavailable(
366
+ "本机没有可用的文件锁后端(既无 fcntl 也无 msvcrt)⇒ 无法保证互斥;"
367
+ "如需显式降级请用 best_effort(会响亮告警)")
368
+ fd = self._ensure_open()
369
+ wait = self.timeout if timeout is None else timeout
370
+ if wait is None:
371
+ if self.blocking:
372
+ self._held = bool(self._acquire_forever(fd))
373
+ else:
374
+ self._held = bool(self._acquire_deadline(fd, 0.0))
375
+ else:
376
+ self._held = bool(self._acquire_deadline(fd, float(wait)))
377
+ return self._held
378
+
379
+ def try_acquire(self, timeout: float = 0.0) -> bool:
380
+ """非阻塞试一次(`timeout>0` 则最多等这么久)。拿不到 ⇒ False。"""
381
+ return self.acquire(timeout=timeout)
382
+
383
+ def _unlock(self) -> None:
384
+ fp = self.fp
385
+ if fp is None:
386
+ return
387
+ fd = int(fp.fileno())
388
+ if _BACKEND == "fcntl":
389
+ _fcntl.flock(fd, _fcntl.LOCK_UN) # ← 与旧代码**同一次**系统调用
390
+ return
391
+ pos = _fd_tell(fd)
392
+ try:
393
+ os.lseek(fd, _WIN_LOCK_OFFSET, os.SEEK_SET)
394
+ _msvcrt.locking(fd, _msvcrt.LK_UNLCK, _LOCK_BYTES) # 必须与加锁**同区间**
395
+ finally:
396
+ _fd_restore(fd, pos)
397
+
398
+ def release(self) -> None:
399
+ """解锁;路径模式再关掉自己的 fd。**不抛**,但失败会响亮出声(`last_error` 也留证)。
400
+
401
+ 为什么敢不抛:两种后端都保证"fd 关闭 / 进程退出即释放",所以"解锁调用失败"不等于
402
+ "锁没放掉";在这个位置上抛异常只会把调用方真正要看的错误盖掉(旧实现是 `except: pass`,
403
+ 连声音都没有 —— 这里至少喊一声)。
404
+ """
405
+ was_held, self._held = self._held, False
406
+ if was_held and self.fp is not None:
407
+ try:
408
+ self._unlock()
409
+ except Exception as exc: # noqa: BLE001
410
+ self.last_error = exc
411
+ _warn("解锁失败(%s):%s —— 随后的 close()/进程退出会释放该锁"
412
+ % (self.path or "-", exc), dedup="unlock:%s" % self.path)
413
+ self.close()
414
+
415
+ # ── 上下文管理器 ──
416
+ def __enter__(self) -> "FileLock":
417
+ if not self.acquire():
418
+ raise LockTimeout("未在超时内拿到锁:%s(site=%s)" % (self.path or "-", self.site or "-"))
419
+ return self
420
+
421
+ def __exit__(self, exc_type, exc, tb) -> bool:
422
+ self.release()
423
+ return False # 绝不吞调用方的异常
424
+
425
+
426
+ @contextmanager
427
+ def best_effort(target: Any, site: str = "", note: str = "", **kw: Any) -> Iterator[Optional[FileLock]]:
428
+ """**策略 (b)**:锁不可用 ⇒ 响亮告警(每次计数 / 每进程每 site 喊一次)后**无锁继续**。
429
+
430
+ 只给"追加 / 审计写"这类**自身约定就是不阻断主流程**的场景用;互斥类锁一律直接用 `FileLock`
431
+ 走策略 (a) fail-closed。拿到锁时 `yield` 锁对象,降级时 `yield None`;两种都在退出时释放。
432
+ `ARGENT_LOCK_STRICT=1` ⇒ 不降级,直接抛 `LockError`。
433
+ """
434
+ # ⚠️ **默认阻塞**(= 旧实现的 `fcntl.flock(LOCK_EX)`):追加写必须**排队**,不能"试一次就算了"。
435
+ # 2026-10-09 真机(Windows)实测教训:这里若走非阻塞,`.append()` 会在别人持锁时**直接无锁写** ⇒
436
+ # Windows 的 `_O_APPEND`(seek-to-EOF + write 非原子)**丢行**(3 进程 ×15 条只活下 42 条),
437
+ # 而 POSIX 的 `O_APPEND` 原子性**恰好掩盖**这个问题(本地全绿)⇒ 必须由"持锁期间写入"来保证。
438
+ kw.setdefault("blocking", True)
439
+ lk = FileLock(target, site=site, **kw)
440
+ try:
441
+ got = lk.acquire()
442
+ except LockError as exc:
443
+ warn_degraded(site, exc, note) # strict 模式下这里会抛
444
+ try:
445
+ yield None
446
+ finally:
447
+ lk.release()
448
+ return
449
+ if not got:
450
+ # 只有调用方**显式给了 timeout** 才可能走到这里(默认阻塞路径不会):同样按策略 (b) 处理,
451
+ # 但**绝不静默** —— 也绝不假装"拿到了"。
452
+ warn_degraded(site, None, note or "未在超时内拿到锁 ⇒ 无锁继续")
453
+ try:
454
+ yield None
455
+ finally:
456
+ lk.release()
457
+ return
458
+ try:
459
+ yield lk
460
+ finally:
461
+ lk.release()
462
+
463
+
464
+ # ── 自检 CLI(诊断/真机验证用;不参与业务)────────────────────────────────────
465
+ def _main(argv: list) -> int:
466
+ def usage() -> int:
467
+ print(__doc__.split("## 自检")[1].strip(), file=sys.stderr)
468
+ return 2
469
+ if not argv or argv[0] in ("-h", "--help"):
470
+ return usage()
471
+ if argv[0] == "--backend":
472
+ print("backend=%s windows=%s" % (_BACKEND or "none", is_windows()))
473
+ return 0
474
+ if argv[0] == "--version":
475
+ print("filelock (Argent engine, cross-platform exclusive file lock)")
476
+ return 0
477
+ if argv[0] == "--probe" and len(argv) >= 2:
478
+ try:
479
+ lk = FileLock(argv[1], site="cli.probe")
480
+ ok = lk.try_acquire()
481
+ except LockError as exc:
482
+ print("⚠️ 锁不可用:%s" % exc, file=sys.stderr)
483
+ return 2
484
+ if not ok:
485
+ holder = read_lock_holder(argv[1])
486
+ print("busy(被别人持有)%s" % ((":" + holder[:200]) if holder else ""))
487
+ lk.release()
488
+ return 3
489
+ print("acquired(%s,backend=%s)" % (argv[1], _BACKEND))
490
+ lk.release()
491
+ print("released")
492
+ return 0
493
+ if argv[0] == "--hold" and len(argv) >= 3:
494
+ secs = float(argv[2])
495
+ lk = FileLock(argv[1], blocking=True, site="cli.hold")
496
+ lk.acquire()
497
+ print("holding %s for %ss (pid=%d, backend=%s)" % (argv[1], secs, os.getpid(), _BACKEND))
498
+ sys.stdout.flush()
499
+ time.sleep(secs)
500
+ lk.release()
501
+ print("released")
502
+ return 0
503
+ return usage()
504
+
505
+
506
+ if __name__ == "__main__": # pragma: no cover
507
+ raise SystemExit(_main(sys.argv[1:]))