@whyshu/argent-engine 0.7.6 → 0.7.8

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 (43) hide show
  1. package/MANIFEST.json +39 -35
  2. package/engine/VERSION +1 -1
  3. package/engine/core/contracts/argent.contracts.json +12 -4
  4. package/engine/scripts/appointment-create.mjs +10 -2
  5. package/engine/scripts/appointment-worker.mjs +72 -15
  6. package/engine/scripts/appointment_grab.py +132 -21
  7. package/engine/scripts/argent-machine-setup.mjs +118 -0
  8. package/engine/scripts/argent-runner.mjs +63 -10
  9. package/engine/scripts/capability-packs.lock.json +54 -51
  10. package/engine/scripts/ci-run.sh +14 -2
  11. package/engine/scripts/collect-evidence.mjs +1 -1
  12. package/engine/scripts/customer-preflight.py +33 -0
  13. package/engine/scripts/flow-auto-retry.py +3 -0
  14. package/engine/scripts/flow-requirements.json +23 -13
  15. package/engine/scripts/flow-session-retention.mjs +88 -0
  16. package/engine/scripts/flow-trigger-wake.mjs +13 -1
  17. package/engine/scripts/flow-trigger.sh +2 -1
  18. package/engine/scripts/flow_config.py +4 -0
  19. package/engine/scripts/flow_failure.py +9 -4
  20. package/engine/scripts/flow_preflight.py +30 -0
  21. package/engine/scripts/flow_retry.py +22 -7
  22. package/engine/scripts/gen-agent-bundles.mjs +1 -1
  23. package/engine/scripts/gen-preset-rows.mjs +2 -2
  24. package/engine/scripts/invoice_lip_runner.py +30 -2
  25. package/engine/scripts/invoice_stageb.py +101 -0
  26. package/engine/scripts/login-readiness.py +220 -0
  27. package/engine/scripts/ml-oauth-setup.mjs +1 -1
  28. package/engine/scripts/orchestrator.py +416 -30
  29. package/engine/scripts/probe_ml_site.py +103 -0
  30. package/engine/scripts/release-argent.sh +15 -1
  31. package/engine/skills/argent-machine-onboarding/SKILL.md +143 -0
  32. package/engine/skills/internal.json +4 -0
  33. package/engine/skills/ml-full-appointment/SKILL.md +21 -13
  34. package/engine/skills/ml-invoice/SKILL.md +1 -1
  35. package/engine/skills/ml-invoice-xzh/SKILL.md +2 -2
  36. package/engine/version.mjs +1 -1
  37. package/engine/verticals/_engine-words.baseline.txt +42 -23
  38. package/engine/verticals/mercadolibre/flows.json +9 -5
  39. package/engine/verticals/mercadolibre/manifest.json +5 -4
  40. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +1 -1
  41. package/engine/verticals/mercadolibre/prompts/daily-report.md +7 -10
  42. package/package.json +1 -1
  43. package/engine/scripts/ml-ads.mjs +0 -181
@@ -8,11 +8,12 @@
8
8
  """
9
9
  import argparse
10
10
  import asyncio
11
+ import time
11
12
  import json
12
13
  import os
14
+ import signal
13
15
  import sys
14
- import time
15
- from datetime import date, datetime, timedelta
16
+ from datetime import date, datetime, timedelta, timezone
16
17
  from pathlib import Path
17
18
  from types import SimpleNamespace
18
19
 
@@ -86,7 +87,48 @@ def load_task() -> dict:
86
87
 
87
88
 
88
89
  def iso_day(d) -> date:
89
- return datetime.fromisoformat(str(d)[:10]).date()
90
+ # 任务日期由服务端按 Asia/Shanghai 00:00 存成 UTC(如 10/21 → 10/20T16:00Z);按中国日期还原,
91
+ # ⛔ 不能用 str(d)[:10] 直接截 UTC,否则会整体早一天。
92
+ dt = datetime.fromisoformat(str(d).replace('Z', '+00:00'))
93
+ if dt.tzinfo is None:
94
+ dt = dt.replace(tzinfo=timezone.utc)
95
+ return dt.astimezone(timezone(timedelta(hours=8))).date()
96
+
97
+
98
+ async def _close_appointment_tab(orch) -> int:
99
+ """停止/失败时关闭本任务用的预约标签页;若它是窗口最后 1 个标签,先开一个占位页再关。
100
+
101
+ 紫鸟窗口守卫要求不能把最后标签关掉(会退出 Chromium 窗口、丢 CDP)。
102
+ 这里用 about:blank 占位保持窗口存活,再关闭预约页,避免用户看到残留任务标签。
103
+ """
104
+ closed = 0
105
+ try:
106
+ page = getattr(orch.browser, 'page', None)
107
+ if page is None or page.is_closed():
108
+ return 0
109
+ url = str(getattr(page, 'url', '') or '')
110
+ # 只清理本任务/预约页,避免误关用户主门户标签
111
+ if ('appointment-v2' not in url) and (str(getattr(orch.state, 'shipment_id', '') or '') not in url):
112
+ return 0
113
+ ctx = page.context
114
+ if len(ctx.pages) <= 1:
115
+ try:
116
+ await ctx.new_page() # 占位,保持窗口不退出
117
+ except Exception:
118
+ return 0
119
+ await page.close()
120
+ closed = 1
121
+ orch._log(' 🗂️ 已关闭抢预约任务标签页(保留占位页,防窗口退出)')
122
+ except Exception as e:
123
+ try: orch._log(f' ⚠️ 关闭预约标签页失败:{str(e)[:120]}')
124
+ except Exception: pass
125
+ return closed
126
+
127
+
128
+ def _bump_page_check(orch) -> int:
129
+ n = int(getattr(orch, '_appt_page_checks', 0)) + 1
130
+ setattr(orch, '_appt_page_checks', n)
131
+ return n
90
132
 
91
133
 
92
134
  async def prepare_appointment_page(orch) -> None:
@@ -94,6 +136,7 @@ async def prepare_appointment_page(orch) -> None:
94
136
  last = None
95
137
  for attempt in range(3):
96
138
  try:
139
+ _bump_page_check(orch)
97
140
  await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
98
141
  if await orch._has_existing_appointment():
99
142
  raise RuntimeError('已有预约存在,不重复抢')
@@ -213,10 +256,17 @@ async def run_task(task: dict) -> dict:
213
256
  platform = str((info or {}).get('platform') or '')
214
257
  except Exception:
215
258
  platform = ''
216
- is_mx = (not platform) or any(k in platform for k in ('墨西哥', 'Mexico', 'México', 'MLM'))
259
+ _pf = str(platform or '').lower()
260
+ is_mx = (not platform) or any(k in _pf for k in ('墨西哥', 'mexico', 'méxico', 'mlm', 'mercadolibre', 'mercado libre'))
217
261
  if not is_mx:
218
262
  return {'status': 'failed', 'summary': f'抢预约当前仅支持墨西哥站(MLM);{store} 属于 {platform},暂未适配,请勿执行'}
219
263
  date_mode = task.get('dateMode') or 'range'
264
+ weekdays = {int(x) for x in (task.get('weekdays') or []) if str(x).isdigit() and 1 <= int(x) <= 7}
265
+ time_from = str(task.get('timeFrom') or '').strip()
266
+ time_to = str(task.get('timeTo') or '').strip()
267
+
268
+ def weekday_allowed(d: date) -> bool:
269
+ return (not weekdays) or d.isoweekday() in weekdays
220
270
  from_date = iso_day(task['dateFrom']) if date_mode == 'range' else None
221
271
  to_date = iso_day(task['dateTo']) if date_mode == 'range' else None
222
272
  cfg = O.load_config()
@@ -252,6 +302,7 @@ async def run_task(task: dict) -> dict:
252
302
  cycle_no = 0
253
303
  last_states = {}
254
304
  while True:
305
+ cycle_started = time.monotonic()
255
306
  cycle_no += 1
256
307
  if date_mode == 'available_days':
257
308
  need = int(task.get('availableDays') or 0)
@@ -259,18 +310,31 @@ async def run_task(task: dict) -> dict:
259
310
  tomorrow = (today + timedelta(days=1)).isoformat()
260
311
  states = {k: v for k, v in dict(scan['states']).items() if k >= tomorrow}
261
312
  targets = [date.fromisoformat(x) for x in scan['selected']]
313
+ targets = [d for d in targets if weekday_allowed(d)]
262
314
  if not targets:
263
- print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'dateStates': states, 'summary': '未发现可选日'}, ensure_ascii=False), flush=True)
315
+ print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states, 'summary': '未发现可选日'}, ensure_ascii=False), flush=True)
264
316
  for d in targets:
265
317
  offset = (d - today).days
266
318
  try:
319
+ _bump_page_check(orch)
267
320
  await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
268
- await orch._book_appointment(hour_idx=0, date_offset=offset)
269
- states[d.isoformat()] = 'booked'
270
- return {'status': 'booked', 'date': d.isoformat(), 'slot': 'first', 'dateStates': states, 'summary': f'{d.isoformat()} 预约成功', 'windowMode': 'available_days'}
321
+ # 与 FULL 步骤4、其他抢预约任务共用同店预约锁:只串行化“提交预约”,不占整个流程
322
+ orch._acquire_store_step4_lock(force=True)
323
+ try:
324
+ await orch._book_appointment(hour_idx=0, date_offset=offset,
325
+ time_from=time_from, time_to=time_to)
326
+ finally:
327
+ orch._release_store_step4_lock()
328
+ actual = str(getattr(orch, '_last_booked_date', '') or d.isoformat())
329
+ states[actual] = 'booked'
330
+ slot = str(getattr(orch, '_last_booked_slot', '') or 'first')
331
+ return {'status': 'booked', 'date': actual, 'slot': slot, 'dateStates': states, 'summary': f'{actual} {slot} 预约成功', 'windowMode': 'available_days'}
271
332
  except Exception as exc:
272
333
  msg = str(exc)
273
- if any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间')):
334
+ if '不可选' in msg or '目标日期' in msg:
335
+ states[d.isoformat()] = 'disabled'
336
+ elif any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间',
337
+ '指定时间范围内无可预约时段')):
274
338
  states[d.isoformat()] = 'selectable_no_slot'
275
339
  else:
276
340
  states[d.isoformat()] = 'selectable_booking_failed'
@@ -279,41 +343,78 @@ async def run_task(task: dict) -> dict:
279
343
  targets = []
280
344
  d = max(from_date, today + timedelta(days=1))
281
345
  while d <= to_date:
282
- targets.append(d); d += timedelta(days=1)
346
+ if weekday_allowed(d):
347
+ targets.append(d)
348
+ d += timedelta(days=1)
283
349
  if not targets:
284
- return {'status': 'failed', 'summary': '日期范围内没有可预约日期(今天及以前不算)'}
350
+ return {'status': 'failed', 'summary': '日期范围内没有符合星期条件的可预约日期(今天及以前不算)'}
285
351
  states = {d.isoformat(): 'pending' for d in targets}
352
+ # 2026-10-21:range 模式也改为**每轮只打开一次日历**。
353
+ # 先在同一页面上扫完本轮所有目标日期;只有日期可选时才进入预约。
354
+ # 旧实现每个日期都 prepare_appointment_page() 重新打开页面,导致
355
+ # 一轮 3 个日期产生 6~10 次页面检查(用户看到的“轮次对不上”根因)。
356
+ prepared = False
286
357
  for d in targets:
287
358
  offset = (d - today).days
288
359
  try:
289
- await prepare_appointment_page(orch)
360
+ if not prepared:
361
+ await prepare_appointment_page(orch)
362
+ prepared = True
290
363
  info = await find_date_cell(orch, d)
291
364
  if not info.get('found'):
292
- states[d.isoformat()] = 'date_not_found'; continue
365
+ states[d.isoformat()] = 'date_not_found'
366
+ print(json.dumps({'event': 'progress', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
367
+ continue
293
368
  if info.get('disabled'):
294
- states[d.isoformat()] = 'disabled'; continue
369
+ states[d.isoformat()] = 'disabled'
370
+ print(json.dumps({'event': 'progress', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
371
+ continue
295
372
  except Exception as exc:
373
+ prepared = False
296
374
  states[d.isoformat()] = 'page_error'
297
- orch._log(f' ⚠️ {d} 分类失败: {str(exc)[:120]}'); continue
375
+ orch._log(f' ⚠️ {d} 分类失败: {str(exc)[:120]}')
376
+ print(json.dumps({'event': 'progress', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
377
+ continue
378
+ print(json.dumps({'event': 'progress', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
298
379
  try:
299
- await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
300
- await orch._book_appointment(hour_idx=0, date_offset=offset)
380
+ # 与 FULL 步骤4、其他抢预约任务共用同店预约锁:只串行化“提交预约”
381
+ orch._acquire_store_step4_lock(force=True)
382
+ try:
383
+ # range 模式严格目标日期:只点运营给的日期,⛔ 不允许 _pick_date 跳到范围外
384
+ await orch._book_appointment(hour_idx=0, date_offset=offset, exact_date=d.isoformat(),
385
+ time_from=time_from, time_to=time_to)
386
+ finally:
387
+ orch._release_store_step4_lock()
388
+ actual = str(getattr(orch, '_last_booked_date', '') or d.isoformat())
389
+ if actual != d.isoformat():
390
+ raise RuntimeError(f'严格日期校验失败:目标 {d.isoformat()},实际页面选中 {actual}(不提交)')
301
391
  states[d.isoformat()] = 'booked'
302
- return {'status': 'booked', 'date': d.isoformat(), 'slot': 'first', 'dateStates': states, 'summary': f'{d.isoformat()} 预约成功'}
392
+ slot = str(getattr(orch, '_last_booked_slot', '') or 'first')
393
+ return {'status': 'booked', 'date': actual, 'slot': slot, 'dateStates': states, 'summary': f'{actual} {slot} 预约成功'}
303
394
  except Exception as exc:
395
+ prepared = False
304
396
  msg = str(exc)
305
- if any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间')):
397
+ if '不可选' in msg or '目标日期' in msg:
398
+ states[d.isoformat()] = 'disabled'
399
+ elif any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间',
400
+ '指定时间范围内无可预约时段')):
306
401
  states[d.isoformat()] = 'selectable_no_slot'
307
402
  else:
308
403
  states[d.isoformat()] = 'selectable_booking_failed'
309
404
  orch._log(f' ⚠️ {d} 预约失败: {msg[:160]}')
310
405
  last_states = states
311
- print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'dateStates': states}, ensure_ascii=False), flush=True)
406
+ print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'checks': int(getattr(orch, '_appt_page_checks', 0)), 'dateStates': states}, ensure_ascii=False), flush=True)
312
407
  if cycles and cycle_no >= cycles:
313
408
  break
314
- await asyncio.sleep(max(10, int(task.get('pollIntervalSec') or 60)))
409
+ interval = max(30, min(600, int(task.get('pollIntervalSec') or 30)))
410
+ elapsed = time.monotonic() - cycle_started
411
+ await asyncio.sleep(max(10, interval - elapsed))
315
412
  return {'status': 'failed', 'dateStates': last_states, 'cycles': cycle_no, 'summary': '本轮日期范围内未抢到预约'}
316
413
  finally:
414
+ try:
415
+ await _close_appointment_tab(orch)
416
+ except Exception:
417
+ pass
317
418
  try:
318
419
  await orch.browser.close()
319
420
  except Exception:
@@ -324,10 +425,20 @@ async def run_task(task: dict) -> dict:
324
425
  pass
325
426
 
326
427
 
428
+ def _on_term(signum, frame):
429
+ # SIGTERM 默认直接退出,不会执行 run_task 的 finally ⇒ 预约标签页残留。
430
+ # 抛 KeyboardInterrupt 交给 asyncio.run 取消主任务,确保 finally 里的标签页清理执行。
431
+ raise KeyboardInterrupt
432
+
433
+
327
434
  def main():
328
435
  task = load_task()
436
+ signal.signal(signal.SIGTERM, _on_term)
437
+ signal.signal(signal.SIGINT, _on_term)
329
438
  try:
330
439
  out = asyncio.run(run_task(task))
440
+ except KeyboardInterrupt:
441
+ out = {'status': 'stopped', 'summary': '收到停止请求,已关闭预约标签页'}
331
442
  except Exception as exc:
332
443
  out = {'status': 'failed', 'summary': f'{type(exc).__name__}: {exc}'}
333
444
  print(json.dumps(out, ensure_ascii=False), flush=True)
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 本机接入 ERP 机器执行面:从剪贴板读取 machine token → 校验 → 写入 agent-report.json。
4
+ *
5
+ * 用法(推荐,token 不进入聊天/命令行历史):
6
+ * node scripts/argent-machine-setup.mjs --check
7
+ * node scripts/argent-machine-setup.mjs --from-clipboard
8
+ *
9
+ * 其他:
10
+ * --config <path> 默认 ~/.dsh/argent/agent-report.json
11
+ * --machine-id <id> 覆盖配置里的 machineId
12
+ * --app-id <id> 覆盖配置里的 tenantAppId
13
+ * --server <url> 覆盖配置里的 serverUrl
14
+ * --token <jwt> ⛔ 不推荐:会进入 shell 历史;仅调试用
15
+ *
16
+ * 红线:不打印 token;不改其他字段;写入前备份;配置文件 600。
17
+ */
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join, resolve } from 'node:path';
22
+
23
+ const args = process.argv.slice(2);
24
+ const has = (name) => args.includes(name);
25
+ const val = (name, fallback = '') => {
26
+ const i = args.indexOf(name);
27
+ return i >= 0 && args[i + 1] ? String(args[i + 1]) : fallback;
28
+ };
29
+ const CONFIG = resolve(val('--config', join(homedir(), '.dsh', 'argent', 'agent-report.json')));
30
+ const CHECK = has('--check');
31
+ const FROM_CLIPBOARD = has('--from-clipboard');
32
+ const TOKEN_ARG = val('--token');
33
+
34
+ function fail(message, code = 1) {
35
+ console.error(`❌ ${message}`);
36
+ process.exit(code);
37
+ }
38
+ function readConfig() {
39
+ if (!existsSync(CONFIG)) fail(`配置文件不存在:${CONFIG}`);
40
+ try { return JSON.parse(readFileSync(CONFIG, 'utf8')); } catch (e) { fail(`配置文件不是合法 JSON:${e.message}`); }
41
+ }
42
+ function clipboard() {
43
+ try {
44
+ if (process.platform === 'darwin') return execFileSync('pbpaste', [], { encoding: 'utf8' }).trim();
45
+ if (process.platform === 'win32') return execFileSync('powershell.exe', ['-NoProfile', '-Command', 'Get-Clipboard'], { encoding: 'utf8' }).trim();
46
+ try { return execFileSync('wl-paste', [], { encoding: 'utf8' }).trim(); } catch { /* try xclip */ }
47
+ return execFileSync('xclip', ['-selection', 'clipboard', '-o'], { encoding: 'utf8' }).trim();
48
+ } catch { fail('读取剪贴板失败;请确认已复制 token,或使用 --token(不推荐)'); }
49
+ }
50
+ function decodeJwt(token) {
51
+ const parts = String(token).split('.');
52
+ if (parts.length !== 3) fail('token 不是 JWT 格式');
53
+ try {
54
+ const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
55
+ return payload;
56
+ } catch { fail('token payload 解析失败'); }
57
+ }
58
+ async function verify({ token, serverUrl, appId, machineId }) {
59
+ let res;
60
+ try {
61
+ res = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/argent/v1/tenants/${encodeURIComponent(appId)}/machines/${encodeURIComponent(machineId)}/commands?limit=1`, {
62
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
63
+ });
64
+ } catch (e) { fail(`无法连接 ERP:${e.message}`); }
65
+ if (res.status === 401) fail('token 无效或已过期');
66
+ if (res.status === 403) fail('token 与机器/租户不匹配,或机器已被吊销');
67
+ if (!res.ok) fail(`ERP 校验失败:HTTP ${res.status}`);
68
+ }
69
+
70
+ const cfg = readConfig();
71
+ const machineId = String(val('--machine-id', cfg.machineId || '')).trim();
72
+ const appId = String(val('--app-id', cfg.tenantAppId || '')).trim();
73
+ const serverUrl = String(val('--server', cfg.serverUrl || 'https://whyshu.com')).replace(/\/+$/, '');
74
+ if (!machineId || !appId || !serverUrl) fail('配置缺少 machineId / tenantAppId / serverUrl,请先用 --machine-id/--app-id/--server 补齐');
75
+
76
+ let token = '';
77
+ if (CHECK) {
78
+ token = String(cfg.machineToken || cfg.runnerToken || '').trim();
79
+ if (!token) fail('当前配置没有 machineToken/runnerToken');
80
+ } else if (FROM_CLIPBOARD) {
81
+ token = clipboard();
82
+ if (!token) fail('剪贴板为空;请先在 ERP 复制新 token');
83
+ } else if (TOKEN_ARG) {
84
+ token = String(TOKEN_ARG).trim();
85
+ } else {
86
+ fail('请用 --from-clipboard(推荐)或 --token;详见脚本注释');
87
+ }
88
+
89
+ const claims = decodeJwt(token);
90
+ if (claims.scope !== 'machine') fail(`token scope 不是 machine(实际:${claims.scope || '空'})`);
91
+ if (claims.appId && claims.appId !== appId) fail(`token appId=${claims.appId} 与配置 ${appId} 不一致`);
92
+ if (claims.machineId && claims.machineId !== machineId) fail(`token machineId=${claims.machineId} 与配置 ${machineId} 不一致`);
93
+ if (claims.exp && Number(claims.exp) * 1000 <= Date.now()) fail('token 已过期,请重新签发');
94
+
95
+ await verify({ token, serverUrl, appId, machineId });
96
+ if (CHECK) {
97
+ console.log(`✅ token 有效:${appId} / ${machineId},配置无需修改`);
98
+ process.exit(0);
99
+ }
100
+
101
+ const backup = `${CONFIG}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
102
+ try { copyFileSync(CONFIG, backup); } catch { /* 配置不存在时无需备份 */ }
103
+ const next = {
104
+ ...cfg,
105
+ serverUrl,
106
+ tenantAppId: appId,
107
+ machineId,
108
+ machineToken: token,
109
+ enabled: true,
110
+ chatEnabled: true,
111
+ };
112
+ delete next.runnerToken;
113
+ writeFileSync(CONFIG, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
114
+ chmodSync(CONFIG, 0o600);
115
+ console.log(`✅ 已写入 ${CONFIG}(600)`);
116
+ console.log(`✅ 已校验:${appId} / ${machineId}`);
117
+ console.log(`📦 旧配置备份:${backup}`);
118
+ console.log('下一步:手动重启本机 dsh(脚本不重启服务),然后在 ERP 确认机器在线。');
@@ -61,6 +61,8 @@ function loadConfig() {
61
61
  leaseSec: Number(cfg.leaseSec) || 90,
62
62
  pollWaitMs: Number(cfg.pollWaitMs) || 20000,
63
63
  execTimeoutSec: Number(cfg.execTimeoutSec) || 900,
64
+ batchExecTimeoutSec: Number(process.env.ARGENT_RUNNER_BATCH_TIMEOUT_SEC || cfg.batchExecTimeoutSec) || 43200,
65
+ batchIdleTimeoutSec: Number(process.env.ARGENT_RUNNER_BATCH_IDLE_TIMEOUT_SEC || cfg.batchIdleTimeoutSec) || 1800,
64
66
  runnerId: String(cfg.runnerId || `runner-${hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-')}`),
65
67
  };
66
68
  for (const k of ['serverUrl', 'tenantAppId', 'runnerToken']) {
@@ -132,28 +134,54 @@ async function executeJob(cfg, job) {
132
134
  }
133
135
  log('job.task.running', { jobId: String(job._id), taskId });
134
136
 
135
- const deadline = Date.now() + cfg.execTimeoutSec * 1000;
136
- while (Date.now() < deadline) {
137
+ const isBatch = /--records\b/.test(String(job.prompt || ''));
138
+ const timeoutSec = isBatch ? Math.max(Number(cfg.execTimeoutSec) || 900, Number(cfg.batchExecTimeoutSec) || 43200) : (Number(cfg.execTimeoutSec) || 900);
139
+ const startedAt = Date.now();
140
+ const hardDeadline = startedAt + timeoutSec * 1000;
141
+ let lastProgressAt = startedAt;
142
+ let lastServerPollAt = 0;
143
+ while (Date.now() < hardDeadline) {
137
144
  await sleep(2000);
138
145
  const state = await tbState(cfg);
139
146
  const task = (state.tasks || []).find((t) => t.id === taskId);
140
147
  if (!task) continue;
148
+ // 本地任务自身更新也视为活跃
149
+ if (task.updatedAt) {
150
+ const t = new Date(task.updatedAt).getTime();
151
+ if (Number.isFinite(t) && t > lastProgressAt) lastProgressAt = t;
152
+ }
141
153
  const execs = task.executions || [];
142
154
  const latest = execs[execs.length - 1];
143
155
  if (latest && latest.endedAt) {
144
156
  const outcome = latest.result === 'succeeded' ? 'succeeded' : 'failed';
145
157
  return { outcome, summary: latest.error ? String(latest.error).slice(0, 500) : '', sessionRef: latest.sessionId || '', taskId };
146
158
  }
159
+ if (isBatch && Date.now() - lastServerPollAt > 15000) {
160
+ lastServerPollAt = Date.now();
161
+ try {
162
+ const p = await serverCall(cfg, `/jobs/${job._id}/progress`);
163
+ const at = p?.data?.lastProgressAt ? new Date(p.data.lastProgressAt).getTime() : 0;
164
+ if (Number.isFinite(at) && at > lastProgressAt) lastProgressAt = at;
165
+ } catch { /* 进度探针暂不可用不判失败 */ }
166
+ }
167
+ if (isBatch) {
168
+ const idleSec = Number(cfg.batchIdleTimeoutSec) || 1800;
169
+ if (Date.now() - lastProgressAt > idleSec * 1000) {
170
+ return { outcome: 'failed', summary: `批任务无步骤进度超过 ${Math.round(idleSec / 60)} 分钟(lastProgress=${new Date(lastProgressAt).toISOString()})`, taskId };
171
+ }
172
+ }
147
173
  }
148
- return { outcome: 'failed', summary: `执行超时(>${cfg.execTimeoutSec}s)`, taskId };
174
+ return { outcome: 'failed', summary: `执行超过硬上限(>${Math.round(timeoutSec / 60)} 分钟)`, taskId };
149
175
  }
150
176
 
151
- async function runOnce(cfg) {
177
+ async function claimJob(cfg) {
152
178
  const claim = await serverCall(cfg, '/jobs/claim', { method: 'POST', body: { runnerId: cfg.runnerId, waitMs: ONCE ? 0 : cfg.pollWaitMs, leaseSec: cfg.leaseSec } });
153
- const job = claim?.data?.job;
154
- if (!job) return { claimed: false };
179
+ return claim?.data?.job || null;
180
+ }
181
+
182
+ async function runJob(cfg, job) {
155
183
  log('job.claimed', { jobId: String(job._id), preset: job.preset, attempts: job.attempts, runnerId: cfg.runnerId });
156
- if (DRY_RUN) { log('job.dry-run', { jobId: String(job._id) }); return { claimed: true, dryRun: true }; }
184
+ if (DRY_RUN) { log('job.dry-run', { jobId: String(job._id) }); return { outcome: 'skipped', dryRun: true }; }
157
185
 
158
186
  // 心跳续租(执行期间每 30s)
159
187
  let heartbeatTimer = null;
@@ -193,6 +221,13 @@ async function runOnce(cfg) {
193
221
  }
194
222
  }
195
223
 
224
+ async function runOnce(cfg) {
225
+ const job = await claimJob(cfg);
226
+ if (!job) return { claimed: false };
227
+ if (DRY_RUN) { log('job.dry-run', { jobId: String(job._id) }); return { claimed: true, dryRun: true }; }
228
+ return runJob(cfg, job);
229
+ }
230
+
196
231
  async function health(cfg) {
197
232
  const out = { server: false, dshWeb: false, tenantAppId: cfg.tenantAppId, runnerId: cfg.runnerId };
198
233
  try {
@@ -215,9 +250,27 @@ async function main() {
215
250
  let stopping = false;
216
251
  process.on('SIGINT', () => { stopping = true; log('runner.stop', {}); process.exit(0); });
217
252
  process.on('SIGTERM', () => { stopping = true; log('runner.stop', {}); process.exit(0); });
218
- do {
219
- try { await runOnce(cfg); } catch (error) { log('runner.error', { message: error.message }); await sleep(3000); }
220
- } while (!ONCE && !stopping);
253
+ const concurrency = Math.max(1, Math.min(5, Number(process.env.ARGENT_RUNNER_CONCURRENCY || cfg.concurrency || 2) || 2));
254
+ log('runner.concurrency', { runnerId: cfg.runnerId, concurrency });
255
+ if (ONCE) {
256
+ try { await runOnce(cfg); } catch (error) { log('runner.error', { message: error.message }); }
257
+ return;
258
+ }
259
+ const active = new Set();
260
+ for (;;) {
261
+ while (active.size < concurrency) {
262
+ let job;
263
+ try { job = await claimJob(cfg); } catch (error) { log('runner.error', { message: error.message }); await sleep(3000); break; }
264
+ if (!job) break;
265
+ const p = runJob(cfg, job)
266
+ .catch((error) => log('runner.error', { jobId: String(job._id), message: error.message }))
267
+ .finally(() => active.delete(p));
268
+ active.add(p);
269
+ }
270
+ if (!active.size) await sleep(3000);
271
+ else await Promise.race([...active, sleep(3000)]);
272
+ if (stopping) process.exit(0);
273
+ }
221
274
  }
222
275
 
223
276
  main().catch((error) => { console.error(`❌ runner 失败: ${error.message}`); process.exit(1); });