@whyshu/argent-engine 0.7.5 → 0.7.7
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 +39 -35
- package/engine/VERSION +1 -1
- package/engine/core/contracts/argent.contracts.json +12 -4
- package/engine/scripts/appointment-create.mjs +37 -2
- package/engine/scripts/appointment-worker.mjs +72 -15
- package/engine/scripts/appointment_grab.py +141 -20
- package/engine/scripts/argent-machine-setup.mjs +118 -0
- package/engine/scripts/argent-runner.mjs +63 -10
- package/engine/scripts/capability-packs.lock.json +54 -51
- package/engine/scripts/ci-run.sh +14 -2
- package/engine/scripts/collect-evidence.mjs +1 -1
- package/engine/scripts/customer-preflight.py +33 -0
- package/engine/scripts/flow-auto-retry.py +3 -0
- package/engine/scripts/flow-requirements.json +23 -13
- package/engine/scripts/flow-session-retention.mjs +88 -0
- package/engine/scripts/flow-trigger-wake.mjs +13 -1
- package/engine/scripts/flow-trigger.sh +2 -1
- package/engine/scripts/flow_config.py +4 -0
- package/engine/scripts/flow_failure.py +9 -4
- package/engine/scripts/flow_preflight.py +30 -0
- package/engine/scripts/flow_retry.py +22 -7
- package/engine/scripts/gen-agent-bundles.mjs +1 -1
- package/engine/scripts/gen-preset-rows.mjs +2 -2
- package/engine/scripts/invoice_lip_runner.py +30 -2
- package/engine/scripts/invoice_stageb.py +101 -0
- package/engine/scripts/login-readiness.py +220 -0
- package/engine/scripts/ml-oauth-setup.mjs +1 -1
- package/engine/scripts/orchestrator.py +416 -30
- package/engine/scripts/probe_ml_site.py +103 -0
- package/engine/scripts/release-argent.sh +26 -2
- package/engine/skills/argent-machine-onboarding/SKILL.md +143 -0
- package/engine/skills/internal.json +4 -0
- package/engine/skills/ml-full-appointment/SKILL.md +32 -13
- package/engine/skills/ml-invoice/SKILL.md +1 -1
- package/engine/skills/ml-invoice-xzh/SKILL.md +2 -2
- package/engine/version.mjs +1 -1
- package/engine/verticals/_engine-words.baseline.txt +42 -23
- package/engine/verticals/mercadolibre/flows.json +9 -5
- package/engine/verticals/mercadolibre/manifest.json +5 -4
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +1 -1
- package/engine/verticals/mercadolibre/prompts/daily-report.md +7 -10
- package/package.json +1 -1
- package/engine/scripts/ml-ads.mjs +0 -181
|
@@ -625,6 +625,20 @@ def _notify_mode() -> str:
|
|
|
625
625
|
return mode if mode in NOTIFY_MODES else "final" # 非法值按缺省(保守:不发步骤)
|
|
626
626
|
|
|
627
627
|
|
|
628
|
+
DONE_CARD_MODE_ENV = "FULL_DONE_CARD_MODE"
|
|
629
|
+
DONE_CARD_MODES = ("single", "batch", "off")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _done_card_mode() -> str:
|
|
633
|
+
"""FULL 完成卡粒度:single=每票发(旧缺省);batch=只由 batch 汇总发;off=都不发。
|
|
634
|
+
|
|
635
|
+
客户机 2026-10-21 口径:不要再逐票发 FULL 成功卡 ⇒ 设 FULL_DONE_CARD_MODE=batch。
|
|
636
|
+
"""
|
|
637
|
+
raw = _profile_from_deploy(DONE_CARD_MODE_ENV, "") or "single"
|
|
638
|
+
mode = raw.strip().lower()
|
|
639
|
+
return mode if mode in DONE_CARD_MODES else "single"
|
|
640
|
+
|
|
641
|
+
|
|
628
642
|
def _should_send(kind: str, final: bool = False) -> bool:
|
|
629
643
|
"""该条通知要不要真的对外发(纯函数,便于回归)。
|
|
630
644
|
|
|
@@ -639,6 +653,15 @@ def _should_send(kind: str, final: bool = False) -> bool:
|
|
|
639
653
|
return bool(final)
|
|
640
654
|
|
|
641
655
|
|
|
656
|
+
def _message_id_from_output(out: str) -> str:
|
|
657
|
+
"""从 lark-cli 的 JSON 输出里取 message_id(兼容 data.message_id / message_id)。"""
|
|
658
|
+
try:
|
|
659
|
+
d = json.loads(str(out or ""))
|
|
660
|
+
return str(d.get("data", {}).get("message_id") or d.get("message_id") or "")
|
|
661
|
+
except Exception:
|
|
662
|
+
return ""
|
|
663
|
+
|
|
664
|
+
|
|
642
665
|
def _notify_fact_path() -> Path:
|
|
643
666
|
try:
|
|
644
667
|
return _flow_data_dir() / NOTIFY_FACT_FILE
|
|
@@ -2450,6 +2473,35 @@ STEP_FALLBACKS: dict[int, dict[str, tuple[Optional[str], list[str]]]] = {
|
|
|
2450
2473
|
# 修复「1 号格做月边界」缺陷:11 号会被误判为新月初(8/13 双月视图截断 bug)——
|
|
2451
2474
|
# 改为逐格月份过渡分配(大数→1 回落即进下月,月份可越出视图末月=下月尾部格)。
|
|
2452
2475
|
# __DATE_OFFSET__ 为占位符:调用处 replace 为实际 date_offset(JS 含 {} 对象字面量,不能用 .format)。
|
|
2476
|
+
EXACT_DATE_JS = r"""({targetIso}) => {
|
|
2477
|
+
const days = document.querySelectorAll('div.day');
|
|
2478
|
+
const MONTHS = ['enero','febrero','marzo','abril','mayo','junio','julio',
|
|
2479
|
+
'agosto','septiembre','octubre','noviembre','diciembre'];
|
|
2480
|
+
const my = document.querySelector('.focus-ui-datepicker__content__month-year');
|
|
2481
|
+
const header = my ? (my.textContent || '').trim().toLowerCase() : '';
|
|
2482
|
+
const viewMonths = MONTHS.map((m, mi) => header.includes(m) ? mi : -1).filter(x => x >= 0);
|
|
2483
|
+
const ym = header.match(/(\d{4})/);
|
|
2484
|
+
if (!viewMonths.length || !ym) return {found: false, reason: 'header_unreliable', header};
|
|
2485
|
+
const viewYear = parseInt(ym[1], 10);
|
|
2486
|
+
const t = new Date(targetIso + 'T12:00:00');
|
|
2487
|
+
let month = viewMonths[0] - 1, year = viewYear, prev = 0, started = false;
|
|
2488
|
+
for (let i = 0; i < days.length; i++) {
|
|
2489
|
+
const txt = (days[i].textContent || '').trim();
|
|
2490
|
+
if (!/^\d+$/.test(txt)) continue;
|
|
2491
|
+
const d = parseInt(txt, 10);
|
|
2492
|
+
if (d === 1 && (prev > 1 || prev === 0)) {
|
|
2493
|
+
if (!started) { started = true; month = viewMonths[0]; year = viewYear; }
|
|
2494
|
+
else { month++; if (month > 11) { month = 0; year++; } }
|
|
2495
|
+
}
|
|
2496
|
+
prev = d;
|
|
2497
|
+
if (started && year === t.getFullYear() && month === t.getMonth() && d === t.getDate()) {
|
|
2498
|
+
const cls = days[i].className || '';
|
|
2499
|
+
return {found: true, idx: i, disabled: cls.includes('day--disabled'), cls, header};
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return {found: false, reason: 'not_in_view', header};
|
|
2503
|
+
}"""
|
|
2504
|
+
|
|
2453
2505
|
FALLBACK_DATE_JS = r"""() => {
|
|
2454
2506
|
const days = document.querySelectorAll('div.day');
|
|
2455
2507
|
const n = days.length;
|
|
@@ -2930,6 +2982,33 @@ class RequestRateMeter:
|
|
|
2930
2982
|
return f"请求 {self.rate()} 次/分(峰值 {self.peak}/分,整轮 {self.total} 次)"
|
|
2931
2983
|
|
|
2932
2984
|
|
|
2985
|
+
def _tab_close_lock(cdp_url: str):
|
|
2986
|
+
"""跨进程串行化「窗口最后一个标签」守卫;失败时退化为无锁(不影响关闭)。"""
|
|
2987
|
+
try:
|
|
2988
|
+
import fcntl
|
|
2989
|
+
port = str(cdp_url or '').rsplit(':', 1)[-1].strip('/ ')
|
|
2990
|
+
path = f"/tmp/argent-tab-close-{port or 'default'}.lock"
|
|
2991
|
+
f = open(path, 'a+')
|
|
2992
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
2993
|
+
return f
|
|
2994
|
+
except Exception:
|
|
2995
|
+
return None
|
|
2996
|
+
|
|
2997
|
+
|
|
2998
|
+
def _tab_close_unlock(handle) -> None:
|
|
2999
|
+
if handle is None:
|
|
3000
|
+
return
|
|
3001
|
+
try:
|
|
3002
|
+
import fcntl
|
|
3003
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
3004
|
+
except Exception:
|
|
3005
|
+
pass
|
|
3006
|
+
try:
|
|
3007
|
+
handle.close()
|
|
3008
|
+
except Exception:
|
|
3009
|
+
pass
|
|
3010
|
+
|
|
3011
|
+
|
|
2933
3012
|
class PlaywrightClient:
|
|
2934
3013
|
"""Playwright CDP 统一封装:connect_over_cdp 直连紫鸟浏览器(默认 127.0.0.1:52420)。
|
|
2935
3014
|
|
|
@@ -3052,11 +3131,15 @@ class PlaywrightClient:
|
|
|
3052
3131
|
if page is None or page.is_closed():
|
|
3053
3132
|
return
|
|
3054
3133
|
pctx = page.context
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3134
|
+
lock = _tab_close_lock(self.cdp_url)
|
|
3135
|
+
try:
|
|
3136
|
+
if len(pctx.pages) > 1:
|
|
3137
|
+
await page.close()
|
|
3138
|
+
else:
|
|
3139
|
+
print(f"[{time.strftime('%H:%M:%S')}] 🗂️ 记录标签为窗口最后 1 个,保留不关"
|
|
3140
|
+
f"(KB-133 窗口守卫:防紫鸟窗口退出)", file=sys.stderr, flush=True)
|
|
3141
|
+
finally:
|
|
3142
|
+
_tab_close_unlock(lock)
|
|
3060
3143
|
except Exception:
|
|
3061
3144
|
pass
|
|
3062
3145
|
|
|
@@ -3194,12 +3277,16 @@ class PlaywrightClient:
|
|
|
3194
3277
|
if pg.is_closed():
|
|
3195
3278
|
continue
|
|
3196
3279
|
pctx = pg.context
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3280
|
+
lock = _tab_close_lock(self.cdp_url)
|
|
3281
|
+
try:
|
|
3282
|
+
if len(pctx.pages) > 1:
|
|
3283
|
+
await pg.close()
|
|
3284
|
+
closed += 1
|
|
3285
|
+
else:
|
|
3286
|
+
print(f"[{time.strftime('%H:%M:%S')}] 🗂️ 保留最后 1 个标签不关"
|
|
3287
|
+
f"(窗口守卫:防紫鸟窗口退出)", file=sys.stderr, flush=True)
|
|
3288
|
+
finally:
|
|
3289
|
+
_tab_close_unlock(lock)
|
|
3203
3290
|
except Exception:
|
|
3204
3291
|
pass
|
|
3205
3292
|
if closed:
|
|
@@ -6540,6 +6627,10 @@ class FeishuClient:
|
|
|
6540
6627
|
"""
|
|
6541
6628
|
if not record_id or not self.cfg.feishu_ready:
|
|
6542
6629
|
return False
|
|
6630
|
+
# 测试/只取消预约:所有进度写入口统一跳过(含 _finalize_partial 等直接调用点)
|
|
6631
|
+
if os.environ.get("ARGENT_NO_PROGRESS") == "1":
|
|
6632
|
+
print(f"[{time.strftime('%H:%M:%S')}] ⏭ ARGENT_NO_PROGRESS=1:跳过写「当前步骤」", file=sys.stderr, flush=True)
|
|
6633
|
+
return False
|
|
6543
6634
|
label = str(step_label or "")
|
|
6544
6635
|
if "全部完成" in label:
|
|
6545
6636
|
why = ""
|
|
@@ -6815,7 +6906,7 @@ class FeishuClient:
|
|
|
6815
6906
|
def send_card(self, card: dict, fallback_text: str,
|
|
6816
6907
|
profile: Optional[str] = None,
|
|
6817
6908
|
chat_id: Optional[str] = None,
|
|
6818
|
-
final: bool = False) ->
|
|
6909
|
+
final: bool = False) -> str:
|
|
6819
6910
|
"""KB-110:发送 interactive card;失败自动降级为文字(不丢通知)。
|
|
6820
6911
|
|
|
6821
6912
|
profile:通知身份(默认 NOTIFY_PROFILE 客户 Bot);chat_id 覆盖目标群
|
|
@@ -6827,23 +6918,23 @@ class FeishuClient:
|
|
|
6827
6918
|
if not _should_send("card", final=final):
|
|
6828
6919
|
_record_notify_fact("card", target_chat or target_user,
|
|
6829
6920
|
fallback_text or json.dumps(card, ensure_ascii=False))
|
|
6830
|
-
return
|
|
6921
|
+
return ""
|
|
6831
6922
|
if not (self.cfg.feishu_ready and (target_chat or target_user)):
|
|
6832
6923
|
return
|
|
6833
6924
|
eff_profile = profile or self.cfg.notify_profile or NOTIFY_PROFILE
|
|
6834
6925
|
try:
|
|
6835
6926
|
if target_chat:
|
|
6836
|
-
self._run(["im", "+messages-send", "--chat-id", target_chat,
|
|
6927
|
+
out = self._run(["im", "+messages-send", "--chat-id", target_chat,
|
|
6837
6928
|
"--msg-type", "interactive",
|
|
6838
6929
|
"--content", json.dumps(card, ensure_ascii=False)],
|
|
6839
6930
|
timeout=60, retries=1, profile=eff_profile)
|
|
6840
6931
|
else:
|
|
6841
|
-
self._run(["im", "+messages-send", "--user-id", target_user,
|
|
6932
|
+
out = self._run(["im", "+messages-send", "--user-id", target_user,
|
|
6842
6933
|
"--msg-type", "interactive",
|
|
6843
6934
|
"--content", json.dumps(card, ensure_ascii=False)],
|
|
6844
6935
|
timeout=60, retries=1, profile=eff_profile)
|
|
6845
6936
|
self._log(" 📇 已发送飞书卡片通知") if hasattr(self, "_log") else print("[lark-cli] 卡片已发送", flush=True)
|
|
6846
|
-
return
|
|
6937
|
+
return _message_id_from_output(out)
|
|
6847
6938
|
except StepError as exc:
|
|
6848
6939
|
# 降级兜底:卡片失败 → 发原文字(不丢通知)
|
|
6849
6940
|
if hasattr(self, "_log"):
|
|
@@ -6863,6 +6954,17 @@ class FeishuClient:
|
|
|
6863
6954
|
pass
|
|
6864
6955
|
|
|
6865
6956
|
|
|
6957
|
+
def update_card(self, message_id: str, card: dict, profile: Optional[str] = None) -> str:
|
|
6958
|
+
"""原地更新已发送的 interactive 卡片;返回 message_id(失败抛 StepError)。"""
|
|
6959
|
+
if not message_id:
|
|
6960
|
+
raise StepError(0, "cli_error", "update_card 缺少 message_id")
|
|
6961
|
+
eff_profile = profile or self.cfg.notify_profile or NOTIFY_PROFILE
|
|
6962
|
+
out = self._run(["im", "messages", "patch", "--message-id", message_id,
|
|
6963
|
+
"--data", json.dumps({"content": json.dumps(card, ensure_ascii=False)}, ensure_ascii=False)],
|
|
6964
|
+
timeout=60, retries=1, profile=eff_profile)
|
|
6965
|
+
return _message_id_from_output(out) or message_id
|
|
6966
|
+
|
|
6967
|
+
|
|
6866
6968
|
# ────────────────────────────────────────────────
|
|
6867
6969
|
# JS 片段(仅保留数据提取类;交互全部用 Playwright 原生 click/fill)
|
|
6868
6970
|
# ────────────────────────────────────────────────
|
|
@@ -7387,8 +7489,21 @@ class Orchestrator:
|
|
|
7387
7489
|
ticket_attestation=self._ticket_attestation())
|
|
7388
7490
|
self.feishu.set_confirm(self.state.record_id, False)
|
|
7389
7491
|
self._log(f" ✅ 整票完成({why})")
|
|
7492
|
+
if getattr(self, "_batch_card_mode", False):
|
|
7493
|
+
# 批量模式:单票不再发完成卡,统一由批次卡片汇总
|
|
7494
|
+
return True
|
|
7390
7495
|
if not notify:
|
|
7391
7496
|
return True
|
|
7497
|
+
# 2026-10-21 客户口径:不再逐票发 FULL 成功卡;batch 模式只允许批次汇总卡。
|
|
7498
|
+
mode = _done_card_mode()
|
|
7499
|
+
if mode == "off" or (mode == "batch" and not getattr(self, "_batch_card_mode", False)):
|
|
7500
|
+
try:
|
|
7501
|
+
_record_notify_fact("card", self.cfg.feishu_chat or self.cfg.feishu_user,
|
|
7502
|
+
f"FULL 完成卡已抑制({DONE_CARD_MODE_ENV}={mode}):"
|
|
7503
|
+
f"货件#{self.state.shipment_id} {self.state.sku}")
|
|
7504
|
+
except Exception:
|
|
7505
|
+
pass
|
|
7506
|
+
return True
|
|
7392
7507
|
# KB-110:完成通知卡片化(绿卡 ✅),失败降级为原文字
|
|
7393
7508
|
# 2026-09-18:这是**唯一默认外发**的通知(final=True)——落内部群、主 Bot 身份
|
|
7394
7509
|
_done_chat, _done_profile = _done_card_sink(self.cfg)
|
|
@@ -7501,6 +7616,10 @@ class Orchestrator:
|
|
|
7501
7616
|
"""
|
|
7502
7617
|
if not self.state.record_id:
|
|
7503
7618
|
return
|
|
7619
|
+
# 测试/只取消预约:不写 FULL 当前步骤(避免测试污染生产 FULL 流程)
|
|
7620
|
+
if os.environ.get("ARGENT_NO_PROGRESS") == "1":
|
|
7621
|
+
self._log(" ⏭ ARGENT_NO_PROGRESS=1:跳过写「当前步骤」")
|
|
7622
|
+
return
|
|
7504
7623
|
labels = STEP_PROGRESS.get(step)
|
|
7505
7624
|
if not labels:
|
|
7506
7625
|
return
|
|
@@ -7520,6 +7639,8 @@ class Orchestrator:
|
|
|
7520
7639
|
"""
|
|
7521
7640
|
if not self.state.record_id:
|
|
7522
7641
|
return
|
|
7642
|
+
if os.environ.get("ARGENT_NO_PROGRESS") == "1":
|
|
7643
|
+
return
|
|
7523
7644
|
labels = STEP_PROGRESS.get(step)
|
|
7524
7645
|
if labels:
|
|
7525
7646
|
base = labels[0]
|
|
@@ -9486,9 +9607,10 @@ class Orchestrator:
|
|
|
9486
9607
|
|
|
9487
9608
|
async def step4_appointment(self) -> None:
|
|
9488
9609
|
self._log("步骤4 货件预约时间")
|
|
9489
|
-
# 同店步骤4互斥锁(方案C,2026-08-14
|
|
9490
|
-
# ML
|
|
9491
|
-
#
|
|
9610
|
+
# 同店步骤4互斥锁(方案C,2026-08-14):**保守串行**,不是已验证的平台规则。
|
|
9611
|
+
# ⚠️ 2026-10-20 复核:没有实验/事故证据证明 ML 同店同时段只能有一个预约;
|
|
9612
|
+
# 这条锁最初只是设计假设。保留它的收益是降低同店并发提交/页态干扰,
|
|
9613
|
+
# 代价是步4串行排队;待双票同店实测后再决定保留或移除。锁覆盖整个步骤4,finally 释放。
|
|
9492
9614
|
self._acquire_store_step4_lock()
|
|
9493
9615
|
try:
|
|
9494
9616
|
# ── 4-pre:**仓容前置探测**(2026-10-10)─────────────────────────────
|
|
@@ -9607,7 +9729,8 @@ class Orchestrator:
|
|
|
9607
9729
|
ev["platform_error"] = plat
|
|
9608
9730
|
return ev
|
|
9609
9731
|
|
|
9610
|
-
async def _book_appointment(self, hour_idx: int, date_offset: int = 1
|
|
9732
|
+
async def _book_appointment(self, hour_idx: int, date_offset: int = 1, exact_date: str = '',
|
|
9733
|
+
time_from: str = '', time_to: str = '') -> None:
|
|
9611
9734
|
"""预约页表单填写+提交:运输方式 → 日期(平台今天+date_offset) → 时段(hour_idx) → 双 Confirmar → 等跳转。
|
|
9612
9735
|
|
|
9613
9736
|
页面必须已在 appointment-v2(调用方保证);hour_idx=0 选第一个可用时段(现状),
|
|
@@ -9789,8 +9912,13 @@ class Orchestrator:
|
|
|
9789
9912
|
# 4c. 日期选择:点击只读日期输入框打开日历,然后灰圈算法选第 30 格
|
|
9790
9913
|
await self.browser.click_selector_with_fallback(4, "date_input")
|
|
9791
9914
|
await asyncio.sleep(1)
|
|
9792
|
-
|
|
9793
|
-
|
|
9915
|
+
if exact_date:
|
|
9916
|
+
picked = await self._pick_exact_date(exact_date)
|
|
9917
|
+
self._last_booked_date = exact_date
|
|
9918
|
+
else:
|
|
9919
|
+
picked = await self._pick_date(date_offset)
|
|
9920
|
+
self._last_booked_date = getattr(self, '_last_picked_date', '') or ''
|
|
9921
|
+
self._log(f" 预约日期: {picked}(严格目标={exact_date or '-'})")
|
|
9794
9922
|
# 4d. 时间选择(div.hour 列表;hour_idx=0 取第一个=现状,冲突重试时递增换时段)
|
|
9795
9923
|
hour_chain = self.sel.chain(4, "hour")
|
|
9796
9924
|
loc = self.browser.page.locator(hour_chain[0])
|
|
@@ -9810,7 +9938,37 @@ class Orchestrator:
|
|
|
9810
9938
|
raise StepError(4, "business",
|
|
9811
9939
|
f"预约失败:可用时段不足(需第 {hour_idx + 1} 个时段,实际 {await loc.count()} 个)",
|
|
9812
9940
|
recovery_attempted=["appointment_hour_retry"])
|
|
9813
|
-
|
|
9941
|
+
# 用户三个条件之「时间范围」:按页面真实时段筛选;无匹配时段 ⇒ 不提交,
|
|
9942
|
+
# 让 appointment worker 把该日标为 selectable_no_slot 并继续下一日/下一轮。
|
|
9943
|
+
chosen_idx = hour_idx
|
|
9944
|
+
hour_texts = []
|
|
9945
|
+
for _i in range(await loc.count()):
|
|
9946
|
+
try:
|
|
9947
|
+
hour_texts.append(((await loc.nth(_i).text_content()) or "").strip())
|
|
9948
|
+
except Exception:
|
|
9949
|
+
hour_texts.append("")
|
|
9950
|
+
if time_from or time_to:
|
|
9951
|
+
def _minutes(t: str) -> int:
|
|
9952
|
+
m = re.search(r"(\d{1,2})[::.](\d{2})", t or "")
|
|
9953
|
+
if not m:
|
|
9954
|
+
return -1
|
|
9955
|
+
return int(m.group(1)) * 60 + int(m.group(2))
|
|
9956
|
+
lo = _minutes(time_from) if time_from else 0
|
|
9957
|
+
hi = _minutes(time_to) if time_to else 24 * 60
|
|
9958
|
+
chosen_idx = -1
|
|
9959
|
+
for _i, _text in enumerate(hour_texts):
|
|
9960
|
+
_start = _minutes(_text)
|
|
9961
|
+
if _start >= 0 and lo <= _start < hi:
|
|
9962
|
+
chosen_idx = _i
|
|
9963
|
+
break
|
|
9964
|
+
if chosen_idx < 0:
|
|
9965
|
+
raise StepError(4, "business",
|
|
9966
|
+
f"预约失败:指定时间范围内无可预约时段({time_from or '-'}~{time_to or '-'};"
|
|
9967
|
+
f"该日实际时段 {len(hour_texts)} 个)",
|
|
9968
|
+
recovery_attempted=["appointment_hour_range"])
|
|
9969
|
+
hour_idx = chosen_idx
|
|
9970
|
+
hour_text = (hour_texts[hour_idx] if hour_idx < len(hour_texts) else "") or ""
|
|
9971
|
+
self._last_booked_slot = hour_text
|
|
9814
9972
|
await loc.nth(hour_idx).click(timeout=15000)
|
|
9815
9973
|
except StepError:
|
|
9816
9974
|
raise
|
|
@@ -9969,6 +10127,75 @@ class Orchestrator:
|
|
|
9969
10127
|
ev = await self._appointment_page_evidence()
|
|
9970
10128
|
return appointment_failure_text(appointment_form_verdict(ev), f"{base}|{await self._calendar_diag()}")
|
|
9971
10129
|
|
|
10130
|
+
async def _selected_date_iso(self) -> str:
|
|
10131
|
+
"""读取当前日历中 day--selected 对应的日期(YYYY-MM-DD);失败返回空串。"""
|
|
10132
|
+
try:
|
|
10133
|
+
return await self.browser.page.evaluate(r"""() => {
|
|
10134
|
+
const days = document.querySelectorAll('div.day');
|
|
10135
|
+
const MONTHS = ['enero','febrero','marzo','abril','mayo','junio','julio',
|
|
10136
|
+
'agosto','septiembre','octubre','noviembre','diciembre'];
|
|
10137
|
+
const my = document.querySelector('.focus-ui-datepicker__content__month-year');
|
|
10138
|
+
const header = my ? (my.textContent || '').trim().toLowerCase() : '';
|
|
10139
|
+
const viewMonths = MONTHS.map((m, mi) => header.includes(m) ? mi : -1).filter(x => x >= 0);
|
|
10140
|
+
const ym = header.match(/(\d{4})/);
|
|
10141
|
+
if (!viewMonths.length || !ym) return '';
|
|
10142
|
+
let month = viewMonths[0] - 1, year = parseInt(ym[1], 10), prev = 0, started = false;
|
|
10143
|
+
for (let i = 0; i < days.length; i++) {
|
|
10144
|
+
const txt = (days[i].textContent || '').trim();
|
|
10145
|
+
if (!/^\d+$/.test(txt)) continue;
|
|
10146
|
+
const d = parseInt(txt, 10);
|
|
10147
|
+
if (d === 1 && (prev > 1 || prev === 0)) {
|
|
10148
|
+
if (!started) { started = true; month = viewMonths[0]; year = parseInt(ym[1], 10); }
|
|
10149
|
+
else { month++; if (month > 11) { month = 0; year++; } }
|
|
10150
|
+
}
|
|
10151
|
+
prev = d;
|
|
10152
|
+
if (started && (days[i].className || '').includes('day--selected')) {
|
|
10153
|
+
return year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0');
|
|
10154
|
+
}
|
|
10155
|
+
}
|
|
10156
|
+
return '';
|
|
10157
|
+
}""") or ""
|
|
10158
|
+
except Exception:
|
|
10159
|
+
return ""
|
|
10160
|
+
|
|
10161
|
+
async def _pick_exact_date(self, target_iso: str) -> str:
|
|
10162
|
+
"""严格选目标日期:只允许点目标日;disabled/找不到 ⇒ 报错,⛔ 不跳日。"""
|
|
10163
|
+
from datetime import date as _date
|
|
10164
|
+
tgt = _date.fromisoformat(target_iso)
|
|
10165
|
+
target_month, target_year = tgt.month - 1, tgt.year
|
|
10166
|
+
days = self.browser.page.locator("div.day")
|
|
10167
|
+
for flip in range(3):
|
|
10168
|
+
st = await self._calendar_state()
|
|
10169
|
+
header = str(st.get("header") or "").lower()
|
|
10170
|
+
months = ['enero','febrero','marzo','abril','mayo','junio','julio',
|
|
10171
|
+
'agosto','septiembre','octubre','noviembre','diciembre']
|
|
10172
|
+
vis = [i for i, m in enumerate(months) if m in header]
|
|
10173
|
+
yr_m = __import__('re').search(r'(\d{4})', header)
|
|
10174
|
+
yr = int(yr_m.group(1)) if yr_m else 0
|
|
10175
|
+
if target_month in vis and (not yr or yr == target_year):
|
|
10176
|
+
r = await self.browser.page.evaluate(EXACT_DATE_JS, {"targetIso": target_iso})
|
|
10177
|
+
if not r.get("found"):
|
|
10178
|
+
raise StepError(4, "selector_not_found",
|
|
10179
|
+
f"预约失败:目标日期 {target_iso} 在日历中找不到(不跳日)",
|
|
10180
|
+
recovery_attempted=["exact_date_strict"])
|
|
10181
|
+
if r.get("disabled"):
|
|
10182
|
+
raise StepError(4, "business",
|
|
10183
|
+
f"目标日期 {target_iso} 不可选(day--disabled);不跳到其他日期",
|
|
10184
|
+
recovery_attempted=["exact_date_strict"])
|
|
10185
|
+
idx = int(r["idx"])
|
|
10186
|
+
txt = (await days.nth(idx).text_content() or "").strip()
|
|
10187
|
+
await days.nth(idx).click(timeout=15000)
|
|
10188
|
+
await asyncio.sleep(1.0)
|
|
10189
|
+
await self._verify_day_selected(days, idx)
|
|
10190
|
+
await self._validate_picked_date()
|
|
10191
|
+
self._last_picked_date = target_iso
|
|
10192
|
+
return txt
|
|
10193
|
+
await self._flip_month(st, target_month, target_year)
|
|
10194
|
+
await asyncio.sleep(0.8)
|
|
10195
|
+
raise StepError(4, "selector_not_found",
|
|
10196
|
+
f"预约失败:翻月后仍未找到目标日期 {target_iso}(不跳日)",
|
|
10197
|
+
recovery_attempted=["exact_date_strict", "exact_date_flip"])
|
|
10198
|
+
|
|
9972
10199
|
async def _pick_date(self, date_offset: int = 1) -> str:
|
|
9973
10200
|
"""灰圈算法:找 div.day--current,从其后的第 date_offset 格(灰圈后 1 格=明天)起选日期(跳过表头与 disabled 格)。
|
|
9974
10201
|
|
|
@@ -10027,6 +10254,7 @@ class Orchestrator:
|
|
|
10027
10254
|
# 8/13 用户实测加固:选日成功后校验所选日期 >= 今天+1(第二天)(防 fallback
|
|
10028
10255
|
# 在翻月失败后误选当月最近可用日,如 8/13 < 8/14 要求)→ 拒绝确认
|
|
10029
10256
|
await self._validate_picked_date()
|
|
10257
|
+
self._last_picked_date = await self._selected_date_iso()
|
|
10030
10258
|
return txt
|
|
10031
10259
|
n = await days.count()
|
|
10032
10260
|
target = idx + date_offset # 灰圈后第 date_offset 格=平台今天+date_offset(跳过表头/disabled 由下方 while 循环处理)
|
|
@@ -10053,6 +10281,7 @@ class Orchestrator:
|
|
|
10053
10281
|
# 8/13 用户实测加固:主路径也校验所选日期 >= 今天+1(灰圈 idx+1 在翻月
|
|
10054
10282
|
# 失败/视图异常时可能指向旧视图过早日期)→ 拒绝确认
|
|
10055
10283
|
await self._validate_picked_date()
|
|
10284
|
+
self._last_picked_date = await self._selected_date_iso()
|
|
10056
10285
|
return txt
|
|
10057
10286
|
raise StepError(4, "selector_not_found",
|
|
10058
10287
|
await self._pick_date_failure_text("翻月后仍无法选中"),
|
|
@@ -12224,7 +12453,7 @@ class Orchestrator:
|
|
|
12224
12453
|
同 record 双写会往生产表写两遍(旧实现是函数内 `import fcntl` + `except: pass` ⇒
|
|
12225
12454
|
Windows 上静默无锁)。被别人持有仍按原语义返回 None(skipped)。
|
|
12226
12455
|
"""
|
|
12227
|
-
if self.args.no_lock: # 并发实测/调试开关:跳过文件锁
|
|
12456
|
+
if self.args.no_lock and not force: # 并发实测/调试开关:跳过文件锁
|
|
12228
12457
|
self._log("⚠️ 并发实测模式(无锁)")
|
|
12229
12458
|
return True
|
|
12230
12459
|
record_id = (self.state.record_id or "").strip()
|
|
@@ -12266,10 +12495,11 @@ class Orchestrator:
|
|
|
12266
12495
|
name = str(self.state.store_name or self.cfg.store_name or "").strip()
|
|
12267
12496
|
return re.sub(r"[^\w.-]+", "_", name).strip("_")
|
|
12268
12497
|
|
|
12269
|
-
def _acquire_store_step4_lock(self) -> None:
|
|
12498
|
+
def _acquire_store_step4_lock(self, force: bool = False) -> None:
|
|
12270
12499
|
"""阻塞获取同店步骤4互斥锁(`$ARGENT_LOCK_DIR/ziniao-store-<key>-step4.lock`,默认 /tmp)。
|
|
12271
12500
|
|
|
12272
|
-
|
|
12501
|
+
⚠️ 这是**保守串行锁**:没有实验/事故证据证明“ML 同店同时段只能有一个预约”;
|
|
12502
|
+
它只用于降低同店并发提交/页态干扰,不是已验证的平台规则(2026-10-20 复核)。
|
|
12273
12503
|
锁随进程退出自动释放:锁对象保存 self._store_step4_lock 防回收。
|
|
12274
12504
|
键口径与执行者锁统一走 `_store_lock_key()`(缺陷⑪)。
|
|
12275
12505
|
|
|
@@ -12278,7 +12508,7 @@ class Orchestrator:
|
|
|
12278
12508
|
且平台侧的残留预约要人工擦。所以锁**不可用** ⇒ 抛 `StepError`(本步明确失败、下轮重试),
|
|
12279
12509
|
绝不静默无锁硬提交。("被别人持有"不是不可用:这里本来就是阻塞等它释放。)
|
|
12280
12510
|
"""
|
|
12281
|
-
if self.args.no_lock: # 并发实测/调试开关:跳过文件锁
|
|
12511
|
+
if self.args.no_lock and not force: # 并发实测/调试开关:跳过文件锁
|
|
12282
12512
|
self._log("⚠️ 并发实测模式(无锁)")
|
|
12283
12513
|
return
|
|
12284
12514
|
store_id = self._store_lock_key()
|
|
@@ -12530,6 +12760,93 @@ class Orchestrator:
|
|
|
12530
12760
|
result["capacity_lift_max"] = _capacity_max_lift
|
|
12531
12761
|
return result
|
|
12532
12762
|
|
|
12763
|
+
# ── 批量汇总卡片(一张卡实时更新)────────────────────────────────────────
|
|
12764
|
+
BATCH_ERP_URL = os.environ.get("ML_ERP_BATCH_URL", "https://whyshu.com/admin/argent/#/office")
|
|
12765
|
+
|
|
12766
|
+
def _batch_card_payload(self, results: list[dict], total: int, done: bool = False) -> dict:
|
|
12767
|
+
rows = []
|
|
12768
|
+
ok = bad = 0
|
|
12769
|
+
for i, r in enumerate(results, 1):
|
|
12770
|
+
st = str(r.get("status") or "")
|
|
12771
|
+
if st == "success":
|
|
12772
|
+
ok += 1; state = "完成"; detail = "已上传标签/箱唛"
|
|
12773
|
+
elif st == "partial":
|
|
12774
|
+
bad += 1; state = "失败"; detail = "部分步骤失败"
|
|
12775
|
+
elif st == "failed":
|
|
12776
|
+
bad += 1; state = "失败"; detail = "失败"
|
|
12777
|
+
elif st == "skipped":
|
|
12778
|
+
state = "跳过"; detail = "跳过"
|
|
12779
|
+
else:
|
|
12780
|
+
state = st or "未知"; detail = ""
|
|
12781
|
+
if st in ("partial", "failed") and r.get("error"):
|
|
12782
|
+
detail = str((r.get("error") or {}).get("message") or detail)[:80]
|
|
12783
|
+
rows.append({
|
|
12784
|
+
"seq": str(i),
|
|
12785
|
+
"record": str(r.get("record_id") or ""),
|
|
12786
|
+
"shipment": str(r.get("shipment_id") or "-"),
|
|
12787
|
+
"sku": str(r.get("sku") or "-"),
|
|
12788
|
+
"state": state,
|
|
12789
|
+
"detail": detail or "-",
|
|
12790
|
+
})
|
|
12791
|
+
title = "✅ FULL 批次完成" if done else "⏳ FULL 批次进行中"
|
|
12792
|
+
header = "green" if done and bad == 0 else ("orange" if done else "blue")
|
|
12793
|
+
elements = [
|
|
12794
|
+
{"tag": "markdown", "content": f"**{self.state.store_name or self.cfg.store_name or '店铺'}**|完成 {len(results)}/{total}|成功 {ok}|失败 {bad}"},
|
|
12795
|
+
]
|
|
12796
|
+
if rows:
|
|
12797
|
+
elements.append({
|
|
12798
|
+
"tag": "table", "page_size": 20, "row_height": "low",
|
|
12799
|
+
"header_style": {"bold": True, "background_style": "grey"},
|
|
12800
|
+
"columns": [
|
|
12801
|
+
{"name": "seq", "display_name": "#", "data_type": "text", "width": "80px"},
|
|
12802
|
+
{"name": "shipment", "display_name": "货件号", "data_type": "text", "width": "120px"},
|
|
12803
|
+
{"name": "sku", "display_name": "SKU", "data_type": "text", "width": "140px"},
|
|
12804
|
+
{"name": "state", "display_name": "状态", "data_type": "text", "width": "90px"},
|
|
12805
|
+
{"name": "detail", "display_name": "结果", "data_type": "text", "width": "260px"},
|
|
12806
|
+
],
|
|
12807
|
+
"rows": rows,
|
|
12808
|
+
})
|
|
12809
|
+
else:
|
|
12810
|
+
elements.append({"tag": "markdown", "content": "暂无完成记录"})
|
|
12811
|
+
elements.append({
|
|
12812
|
+
"tag": "button", "text": {"tag": "plain_text", "content": "查看更多 / 打开 ERP"},
|
|
12813
|
+
"type": "primary_filled", "width": "fill",
|
|
12814
|
+
"behaviors": [{"type": "open_url", "default_url": self.BATCH_ERP_URL}],
|
|
12815
|
+
})
|
|
12816
|
+
return {
|
|
12817
|
+
"schema": "2.0",
|
|
12818
|
+
"config": {"wide_screen_mode": True},
|
|
12819
|
+
"header": {"template": header, "title": {"tag": "plain_text", "content": title}},
|
|
12820
|
+
"body": {"elements": elements},
|
|
12821
|
+
}
|
|
12822
|
+
|
|
12823
|
+
def _batch_card_init(self, total: int) -> None:
|
|
12824
|
+
self._batch_card_mode = True
|
|
12825
|
+
self._batch_card_total = total
|
|
12826
|
+
self._batch_card_message_id = ""
|
|
12827
|
+
if getattr(self.feishu, "dry_run", False):
|
|
12828
|
+
return
|
|
12829
|
+
try:
|
|
12830
|
+
chat, profile = _done_card_sink(self.cfg)
|
|
12831
|
+
self._batch_card_chat = chat
|
|
12832
|
+
self._batch_card_profile = profile
|
|
12833
|
+
self._batch_card_message_id = self.feishu.send_card(
|
|
12834
|
+
self._batch_card_payload([], total, done=False),
|
|
12835
|
+
f"FULL 批次进行中:{self.state.store_name or self.cfg.store_name}(0/{total})",
|
|
12836
|
+
profile=profile, chat_id=chat, final=True)
|
|
12837
|
+
except Exception as exc: # noqa: BLE001
|
|
12838
|
+
self._log(f" ⚠️ 批次卡片初始化失败(不影响流程): {exc}")
|
|
12839
|
+
|
|
12840
|
+
def _batch_card_update(self, results: list[dict], done: bool = False) -> None:
|
|
12841
|
+
if not getattr(self, "_batch_card_message_id", ""):
|
|
12842
|
+
return
|
|
12843
|
+
try:
|
|
12844
|
+
self.feishu.update_card(self._batch_card_message_id,
|
|
12845
|
+
self._batch_card_payload(results, self._batch_card_total, done=done),
|
|
12846
|
+
profile=getattr(self, "_batch_card_profile", None))
|
|
12847
|
+
except Exception as exc: # noqa: BLE001
|
|
12848
|
+
self._log(f" ⚠️ 批次卡片更新失败(不影响流程): {exc}")
|
|
12849
|
+
|
|
12533
12850
|
async def _run_batch(self, mode: str, allow_write: bool,
|
|
12534
12851
|
step_filter: Optional[int]) -> dict[str, Any]:
|
|
12535
12852
|
"""KB-085 批量模式:一次 CDP 连接内处理同店多条记录(每记录独立标签页)。
|
|
@@ -12618,7 +12935,10 @@ class Orchestrator:
|
|
|
12618
12935
|
"results": results, "dry_run": dry_run}
|
|
12619
12936
|
self._log(f" ✅ 单连接已建立(连接 1 次 / 标签页 {len(record_ids)} 个),开始逐记录执行")
|
|
12620
12937
|
results: list[dict[str, Any]] = []
|
|
12938
|
+
self._batch_card_init(len(record_ids))
|
|
12621
12939
|
for idx, rid in enumerate(record_ids):
|
|
12940
|
+
if idx > 0:
|
|
12941
|
+
self._batch_card_update(results, done=False)
|
|
12622
12942
|
self._log(f" ── 记录 {idx + 1}/{len(record_ids)}: {rid} ──")
|
|
12623
12943
|
try:
|
|
12624
12944
|
rec = self.feishu.get_record(rid)
|
|
@@ -12726,6 +13046,7 @@ class Orchestrator:
|
|
|
12726
13046
|
# KB-133:改用带窗口守卫的关标签(关掉窗口最后一个标签会连带 Chromium
|
|
12727
13047
|
# 窗口退出 → 批内下一条记录「未找到窗口」,2026-09-11 实证)
|
|
12728
13048
|
await self.browser.close_record_page(page)
|
|
13049
|
+
self._batch_card_update(results, done=True)
|
|
12729
13050
|
await self.browser.close()
|
|
12730
13051
|
self._log(f"📦 批量模式完成:连接 1 次 / 标签页 {len(record_ids)} 个 / 结果 {len(results)} 条")
|
|
12731
13052
|
_rate = self._rate_summary()
|
|
@@ -12817,8 +13138,9 @@ class Orchestrator:
|
|
|
12817
13138
|
|
|
12818
13139
|
# §8.57:**先拿店铺级执行者锁,再碰店铺窗口**(record 锁在取窗口之后才生效,挡不住抢窗)。
|
|
12819
13140
|
# 拿不到 ⇒ skipped 退出:不写表、不发卡、不算失败。
|
|
12820
|
-
if not self.
|
|
12821
|
-
|
|
13141
|
+
if not getattr(self.args, "no_store_lock", False):
|
|
13142
|
+
if not self.acquire_store_executor_lock():
|
|
13143
|
+
return {"status": "skipped", "reason": "store_busy",
|
|
12822
13144
|
"holder": self._store_exec_holder,
|
|
12823
13145
|
"store": self.state.store_name or self.cfg.store_name}
|
|
12824
13146
|
|
|
@@ -13495,14 +13817,75 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
13495
13817
|
p.add_argument("--store-name", help="店铺名过滤待处理记录(如 1店-子账号;取第一条店铺名匹配的 Pending+就绪 记录)")
|
|
13496
13818
|
# KB-085:批量模式——一次 CDP 连接内处理同店多条记录(每记录独立标签页)
|
|
13497
13819
|
p.add_argument("--records", help="批量模式:逗号分隔记录 ID 列表(poll 按店分组传入;单连接多标签页,需配合 --store-name)")
|
|
13820
|
+
p.add_argument("--parallel-records", type=int, default=0,
|
|
13821
|
+
help="批量模式并行度:>1 时每条记录独立子进程+独立标签页并发执行(默认 0=逐记录串行)")
|
|
13498
13822
|
p.add_argument("--cdp-url", help=f"紫鸟浏览器 CDP 地址(默认 {DEFAULT_CDP_URL})")
|
|
13499
13823
|
p.add_argument("--no-lock", action="store_true",
|
|
13500
13824
|
help="跳过 record flock 锁,仅供并发实测/调试")
|
|
13825
|
+
p.add_argument("--no-store-lock", action="store_true",
|
|
13826
|
+
help="跳过店铺级执行者锁(仅供父批处理已持锁时派出的子进程)")
|
|
13501
13827
|
p.add_argument("--dry-run", action="store_true",
|
|
13502
13828
|
help="lip-poll 预演:识别/拾取判定只打印不写表(KB-095 远程机安全实测)")
|
|
13503
13829
|
return p
|
|
13504
13830
|
|
|
13505
13831
|
|
|
13832
|
+
async def _run_batch_parallel(orch: "Orchestrator", args: argparse.Namespace) -> dict[str, Any]:
|
|
13833
|
+
"""批量并行:父进程持有店铺锁,每条记录派独立 orchestrator 子进程 + 独立标签页。"""
|
|
13834
|
+
record_ids = [r.strip() for r in str(args.records or "").split(",") if r.strip()]
|
|
13835
|
+
if not record_ids:
|
|
13836
|
+
return {"status": "failed", "error": {"step": None, "type": "business", "message": "--records 为空"}}
|
|
13837
|
+
if not orch.acquire_store_executor_lock():
|
|
13838
|
+
return {"status": "batch", "connections": 0, "records": len(record_ids),
|
|
13839
|
+
"results": [{"status": "skipped", "record_id": r, "reason": "store_busy"} for r in record_ids]}
|
|
13840
|
+
parallel = max(1, min(5, int(args.parallel_records or 2)))
|
|
13841
|
+
store = args.store_name or orch.state.store_name or orch.cfg.store_name or ""
|
|
13842
|
+
sem = asyncio.Semaphore(parallel)
|
|
13843
|
+
script = str(Path(__file__).resolve())
|
|
13844
|
+
|
|
13845
|
+
async def run_one(rid: str) -> dict[str, Any]:
|
|
13846
|
+
async with sem:
|
|
13847
|
+
cmd = [sys.executable, script, "--mode", "full", "--record-id", rid,
|
|
13848
|
+
"--store-name", store, "--allow-write", "--no-store-lock"]
|
|
13849
|
+
orch._log(f" 🚀 并行子进程: record={rid}(店铺锁由父进程持有)")
|
|
13850
|
+
proc = await asyncio.create_subprocess_exec(
|
|
13851
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
13852
|
+
env={**os.environ, "ARGENT_NO_STORE_LOCK": "1"},
|
|
13853
|
+
)
|
|
13854
|
+
out, err = await proc.communicate()
|
|
13855
|
+
out_s = out.decode("utf-8", "replace")
|
|
13856
|
+
err_s = err.decode("utf-8", "replace")
|
|
13857
|
+
result = None
|
|
13858
|
+
for line in reversed(out_s.splitlines()):
|
|
13859
|
+
try:
|
|
13860
|
+
j = json.loads(line)
|
|
13861
|
+
if isinstance(j, dict) and j.get("status"):
|
|
13862
|
+
result = j
|
|
13863
|
+
break
|
|
13864
|
+
except Exception:
|
|
13865
|
+
continue
|
|
13866
|
+
if result is None:
|
|
13867
|
+
result = {"status": "failed", "record_id": rid,
|
|
13868
|
+
"error": {"step": None, "type": "cli_error",
|
|
13869
|
+
"message": (err_s or out_s)[-800:] or f"子进程退出码 {proc.returncode}"}}
|
|
13870
|
+
result.setdefault("record_id", rid)
|
|
13871
|
+
result["parallel_child_exit"] = proc.returncode
|
|
13872
|
+
orch._log(f" ✅ 并行子进程完成: record={rid} status={result.get('status')} exit={proc.returncode}")
|
|
13873
|
+
return result
|
|
13874
|
+
|
|
13875
|
+
try:
|
|
13876
|
+
results = await asyncio.gather(*(run_one(r) for r in record_ids))
|
|
13877
|
+
finally:
|
|
13878
|
+
try:
|
|
13879
|
+
lock = getattr(orch, "_store_exec_lock", None)
|
|
13880
|
+
if lock is not None:
|
|
13881
|
+
lock.release()
|
|
13882
|
+
orch._store_exec_lock = None
|
|
13883
|
+
except Exception:
|
|
13884
|
+
pass
|
|
13885
|
+
return {"status": "batch", "connections": 0, "records": len(record_ids),
|
|
13886
|
+
"parallel": parallel, "store": store, "results": list(results)}
|
|
13887
|
+
|
|
13888
|
+
|
|
13506
13889
|
async def _run_and_close(orch: "Orchestrator", args: argparse.Namespace) -> dict[str, Any]:
|
|
13507
13890
|
"""统一运行入口:无论成功/失败/needs_approval/异常,finally 必关自建标签页(防泄漏)。
|
|
13508
13891
|
|
|
@@ -13850,7 +14233,10 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
13850
14233
|
|
|
13851
14234
|
orch = Orchestrator(cfg, sel, args)
|
|
13852
14235
|
try:
|
|
13853
|
-
|
|
14236
|
+
if args.records and int(args.parallel_records or 0) > 1 and args.mode == "full":
|
|
14237
|
+
result = asyncio.run(_run_batch_parallel(orch, args))
|
|
14238
|
+
else:
|
|
14239
|
+
result = asyncio.run(_run_and_close(orch, args))
|
|
13854
14240
|
except StepError as exc: # 顶层兜底(如配置缺失/步骤保护未捕获)
|
|
13855
14241
|
orch._fail_record(exc.step or 0, exc, dry_run=args.mode == "dry-run")
|
|
13856
14242
|
result = {"status": "failed", "record_id": orch.state.record_id or None,
|