@whyshu/argent-engine 0.7.3 → 0.7.5

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 (45) hide show
  1. package/MANIFEST.json +39 -28
  2. package/engine/VERSION +1 -1
  3. package/engine/scripts/appointment-create.mjs +34 -0
  4. package/engine/scripts/appointment-stop.mjs +18 -0
  5. package/engine/scripts/appointment-worker.mjs +96 -0
  6. package/engine/scripts/appointment_grab.py +327 -0
  7. package/engine/scripts/argent-runner.mjs +223 -0
  8. package/engine/scripts/argent-setup-check.mjs +3 -3
  9. package/engine/scripts/capability-packs.lock.json +33 -39
  10. package/engine/scripts/check-agent-catalog.mjs +1 -1
  11. package/engine/scripts/check-config-catalog.mjs +0 -1
  12. package/engine/scripts/collect-evidence.mjs +2 -5
  13. package/engine/scripts/daily-report.mjs +315 -12
  14. package/engine/scripts/dsh-upgrade.mjs +18 -5
  15. package/engine/scripts/flow-requirements.json +5 -7
  16. package/engine/scripts/flow-timers.sh +1 -1
  17. package/engine/scripts/flow-trigger.sh +1 -1
  18. package/engine/scripts/gen-preset-rows.mjs +20 -0
  19. package/engine/scripts/invoice_stageb.py +20 -1
  20. package/engine/scripts/lib/agent-install.mjs +10 -1
  21. package/engine/scripts/lib/daily-report-core.mjs +124 -16
  22. package/engine/scripts/lib/dsh-versions.mjs +17 -1
  23. package/engine/scripts/lib/ml-ads-core.mjs +204 -0
  24. package/engine/scripts/lib/plugin-packages.mjs +1 -0
  25. package/engine/scripts/ml-ads.mjs +181 -0
  26. package/engine/scripts/ml-oauth-setup.mjs +1 -1
  27. package/engine/scripts/p2-report-run.mjs +102 -0
  28. package/engine/scripts/patches/dsh-session--append-message-shape.patch +34 -9
  29. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +44 -0
  30. package/engine/scripts/patches/verify-hardening.mjs +4 -1
  31. package/engine/scripts/patches/verify-turn-outside-replace.mjs +64 -0
  32. package/engine/scripts/poll-fulfillment.sh +1 -1
  33. package/engine/scripts/profile-doctor.mjs +1 -1
  34. package/engine/scripts/python-runtime.sh +16 -4
  35. package/engine/scripts/release-argent.sh +8 -7
  36. package/engine/scripts/session-load-check.mjs +105 -0
  37. package/engine/scripts/sync-scripts.sh +1 -1
  38. package/engine/scripts/xzh-browser.sh +37 -0
  39. package/engine/scripts/xzh_browser.py +49 -0
  40. package/engine/skills/ml-full-appointment/SKILL.md +98 -0
  41. package/engine/version.mjs +1 -1
  42. package/package.json +1 -1
  43. package/engine/scripts/drill-clean-machine.sh +0 -315
  44. package/engine/scripts/install-internal-role.sh +0 -190
  45. package/engine/scripts/install-one-shot.mjs +0 -665
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env python3
2
+ """FULL 抢预约 worker 的浏览器执行器(参数全部来自 AppointmentTask,不读 lip)。
3
+
4
+ - 日期范围严格使用任务 dateFrom/dateTo;
5
+ - 逐日三态:disabled(不可选)/ selectable_no_slot(可选但无时段)/ selectable_booking_failed / booked;
6
+ - 预约成功即停,不执行 FULL 步骤 5-8;
7
+ - 由 appointment-worker.mjs 启动;stdout 最后一行 JSON 为结果。
8
+ """
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import sys
14
+ import time
15
+ from datetime import date, datetime, timedelta
16
+ from pathlib import Path
17
+ from types import SimpleNamespace
18
+
19
+ ROOT = Path(__file__).resolve().parent
20
+ sys.path.insert(0, str(ROOT))
21
+
22
+ import orchestrator as O # noqa: E402
23
+
24
+
25
+ DATE_CELL_JS = r"""(targetIso) => {
26
+ const days = document.querySelectorAll('div.day');
27
+ const MONTHS = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];
28
+ const my = document.querySelector('.focus-ui-datepicker__content__month-year');
29
+ const header = my ? (my.textContent || '').trim().toLowerCase() : '';
30
+ const target = new Date(targetIso + 'T12:00:00');
31
+ const viewMonths = MONTHS.map((m, mi) => header.includes(m) ? mi : -1).filter(x => x >= 0);
32
+ const ym = header.match(/(\d{4})/);
33
+ if (!viewMonths.length || !ym) return {found:false, reason:'header_unreliable', header};
34
+ const viewYear = parseInt(ym[1], 10);
35
+ let month = viewMonths[0] - 1, prev = 0, started = false;
36
+ for (let i = 0; i < days.length; i++) {
37
+ const t = (days[i].textContent || '').trim();
38
+ if (!/^\d+$/.test(t)) continue;
39
+ const d = parseInt(t, 10);
40
+ if (d === 1 && (prev > 1 || prev === 0)) {
41
+ if (!started) { started = true; month = viewMonths[0]; } else { month++; }
42
+ }
43
+ prev = d;
44
+ if (started && month === target.getMonth() && d === target.getDate()) {
45
+ const cls = days[i].className || '';
46
+ return {found:true, idx:i, disabled: cls.includes('day--disabled'), cls, header};
47
+ }
48
+ }
49
+ return {found:false, reason:'not_in_view', header};
50
+ }"""
51
+
52
+
53
+ SCAN_DAYS_JS = r"""() => {
54
+ const days = [...document.querySelectorAll('div.day')];
55
+ const MONTHS = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];
56
+ const my = document.querySelector('.focus-ui-datepicker__content__month-year');
57
+ const header = my ? (my.textContent || '').trim().toLowerCase() : '';
58
+ const viewMonths = MONTHS.map((m, mi) => header.includes(m) ? mi : -1).filter(x => x >= 0);
59
+ const ym = header.match(/(\d{4})/);
60
+ if (!viewMonths.length || !ym) return {header, dates: []};
61
+ const viewYear = parseInt(ym[1], 10);
62
+ let month = viewMonths[0] - 1, year = viewYear, prev = 0, started = false;
63
+ const out = [];
64
+ for (const el of days) {
65
+ const t = (el.textContent || '').trim();
66
+ if (!/^\d+$/.test(t)) continue;
67
+ const d = parseInt(t, 10);
68
+ if (d === 1 && (prev > 1 || prev === 0)) {
69
+ if (!started) { started = true; month = viewMonths[0]; year = viewYear; }
70
+ else { month++; if (month > 11) { month = 0; year++; } }
71
+ }
72
+ prev = d;
73
+ if (!started) continue;
74
+ out.push({date: year + '-' + String(month+1).padStart(2,'0') + '-' + String(d).padStart(2,'0'), disabled: (el.className||'').includes('day--disabled')});
75
+ }
76
+ return {header, dates: out};
77
+ }"""
78
+
79
+
80
+ def load_task() -> dict:
81
+ ap = argparse.ArgumentParser()
82
+ ap.add_argument('--task-json', required=True)
83
+ ap.add_argument('--cdp-hint', default='')
84
+ args = ap.parse_args()
85
+ return json.loads(args.task_json)
86
+
87
+
88
+ def iso_day(d) -> date:
89
+ return datetime.fromisoformat(str(d)[:10]).date()
90
+
91
+
92
+ async def prepare_appointment_page(orch) -> None:
93
+ """进入预约页并选好运输方式/地址,打开日期日历;页面慢/弹窗允许重试。"""
94
+ last = None
95
+ for attempt in range(3):
96
+ try:
97
+ await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
98
+ if await orch._has_existing_appointment():
99
+ raise RuntimeError('已有预约存在,不重复抢')
100
+ page = orch.browser.page
101
+ trigger = page.locator('#shipment-type-selection-dropdown-id-trigger')
102
+ await trigger.wait_for(state='visible', timeout=30000)
103
+ await trigger.click()
104
+ await asyncio.sleep(0.8)
105
+ option = page.get_by_role('option', name='Vehículo particular', exact=False).first
106
+ if await option.count() == 0:
107
+ option = page.get_by_role('option', name='Vehículo', exact=False).first
108
+ if await option.count() == 0:
109
+ raise RuntimeError('未找到 Vehículo 运输选项')
110
+ await option.click()
111
+ await asyncio.sleep(1.2)
112
+ try:
113
+ await orch.browser.page.wait_for_selector('.andes-list__item-action', timeout=8000)
114
+ btns = orch.browser.page.locator('.andes-list__item-action')
115
+ target = None
116
+ for i in range(await btns.count()):
117
+ txt = (await btns.nth(i).text_content() or '')
118
+ if 'MXCD05' in txt:
119
+ target = (btns.nth(i), txt); break
120
+ if target is None:
121
+ raise RuntimeError('未找到指定仓库 MXCD05')
122
+ await target[0].scroll_into_view_if_needed(timeout=5000)
123
+ await asyncio.sleep(0.4)
124
+ await target[0].click()
125
+ try:
126
+ cont = orch.browser.page.locator('button', has_text='Continuar').first
127
+ await cont.click(force=True, timeout=5000)
128
+ except Exception:
129
+ pass
130
+ await asyncio.sleep(1)
131
+ except Exception:
132
+ pass
133
+ await orch.browser.wait_for_selector('input[readonly]', timeout=15, step=4, action='date_input')
134
+ await orch.browser.click_selector_with_fallback(4, 'date_input')
135
+ await asyncio.sleep(1)
136
+ return
137
+ except Exception as exc:
138
+ last = exc
139
+ orch._log(f' ⚠️ 预约页准备第 {attempt + 1}/3 次失败: {str(exc)[:100]}')
140
+ await asyncio.sleep(3)
141
+ raise RuntimeError(f'预约页准备失败:{last}')
142
+
143
+
144
+ async def calendar_month_state(orch) -> dict:
145
+ return await orch._calendar_state()
146
+
147
+
148
+ async def find_date_cell(orch, target: date) -> dict:
149
+ """返回目标 date 的 {found,idx,disabled},必要时翻月。"""
150
+ target_month = target.month - 1
151
+ target_year = target.year
152
+ for _ in range(4):
153
+ st = await calendar_month_state(orch)
154
+ header = (st.get('header') or '').lower()
155
+ months = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']
156
+ vis = [i for i, m in enumerate(months) if m in header]
157
+ yr = year_of(header)
158
+ if target_month in vis and (yr is None or target_year == yr):
159
+ r = await orch.browser.page.evaluate(DATE_CELL_JS, target.isoformat())
160
+ return r if isinstance(r, dict) else json.loads(r)
161
+ await orch._flip_month(st, target_month, target_year)
162
+ await asyncio.sleep(0.8)
163
+ return {'found': False, 'reason': 'flip_failed'}
164
+
165
+
166
+ async def scan_available_days(orch, need: int, max_months: int = 4) -> dict:
167
+ await prepare_appointment_page(orch)
168
+ collected = {}; order = []; selected = []
169
+ for _ in range(max_months):
170
+ r = await orch.browser.page.evaluate(SCAN_DAYS_JS)
171
+ r = r if isinstance(r, dict) else json.loads(r)
172
+ for item in r.get('dates') or []:
173
+ d = item.get('date')
174
+ if not d or d in collected:
175
+ continue
176
+ collected[d] = 'disabled' if item.get('disabled') else 'selectable'
177
+ order.append(d)
178
+ if not item.get('disabled'):
179
+ selected.append(d)
180
+ if len(selected) >= need:
181
+ break
182
+ if len(selected) >= need:
183
+ break
184
+ import re
185
+ header = (r.get('header') or '').lower()
186
+ months = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']
187
+ vis = [i for i, m in enumerate(months) if m in header]
188
+ yr = year_of(header)
189
+ if not vis or yr is None:
190
+ break
191
+ nm = max(vis) + 1
192
+ ny = yr + (1 if nm > 11 else 0)
193
+ nm = nm % 12
194
+ await orch._flip_month(await calendar_month_state(orch), nm, ny)
195
+ await asyncio.sleep(0.8)
196
+ return {'states': collected, 'order': order, 'selected': selected[:need]}
197
+
198
+
199
+ def year_of(header: str):
200
+ import re
201
+ m = re.search(r'(\d{4})', header or '')
202
+ return int(m.group(1)) if m else None
203
+
204
+
205
+ async def run_task(task: dict) -> dict:
206
+ store = task.get('store') or ''
207
+ if not store:
208
+ return {'status': 'failed', 'summary': '任务缺少店铺'}
209
+ date_mode = task.get('dateMode') or 'range'
210
+ from_date = iso_day(task['dateFrom']) if date_mode == 'range' else None
211
+ to_date = iso_day(task['dateTo']) if date_mode == 'range' else None
212
+ cfg = O.load_config()
213
+ cfg.store_name = store
214
+ sel = O.Selectors()
215
+ args = SimpleNamespace(record_id='', sku=task.get('sku', ''), qty=task.get('quantity'), box=task.get('boxCount'),
216
+ shipment_id=task.get('shipmentNo') or '', store_name=store, no_lock=True)
217
+ orch = O.Orchestrator(cfg, sel, args)
218
+ orch.state.store_name = store
219
+ orch.state.record_id = ''
220
+ orch.state.sku = str(task.get('sku') or '')
221
+ orch.state.qty = str(task.get('quantity') or '')
222
+ orch.state.box = str(task.get('boxCount') or '')
223
+ if task.get('shipmentNo'):
224
+ orch.state.shipment_id = str(task['shipmentNo'])
225
+ try:
226
+ if not orch.acquire_store_executor_lock():
227
+ return {'status': 'failed', 'summary': f'同店执行者锁冲突:{orch._store_exec_holder}'}
228
+ _dl, port = orch._open_store()
229
+ if not port:
230
+ return {'status': 'failed', 'summary': 'CDP 端口未发现(店铺窗口未就绪)'}
231
+ orch.browser.cdp_url = f'http://127.0.0.1:{port}'
232
+ await orch._connect_with_port_rediscovery()
233
+ await orch._preflight_store_window(phase='appointment-grab')
234
+ if not orch.state.shipment_id:
235
+ if not orch.state.sku or not orch.state.qty or not orch.state.box:
236
+ return {'status': 'failed', 'summary': 'SKU-only 缺少 sku/qty/box(任务参数不完整)'}
237
+ await orch.step1_prepare()
238
+ await orch.step2_entry()
239
+ await orch.step3_select_product()
240
+ today = (await orch._platform_today()).date()
241
+ cycles = int(task.get('maxCycles') or 0)
242
+ cycle_no = 0
243
+ last_states = {}
244
+ while True:
245
+ cycle_no += 1
246
+ if date_mode == 'available_days':
247
+ need = int(task.get('availableDays') or 0)
248
+ scan = await scan_available_days(orch, need)
249
+ tomorrow = (today + timedelta(days=1)).isoformat()
250
+ states = {k: v for k, v in dict(scan['states']).items() if k >= tomorrow}
251
+ targets = [date.fromisoformat(x) for x in scan['selected']]
252
+ if not targets:
253
+ print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'dateStates': states, 'summary': '未发现可选日'}, ensure_ascii=False), flush=True)
254
+ for d in targets:
255
+ offset = (d - today).days
256
+ try:
257
+ await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
258
+ await orch._book_appointment(hour_idx=0, date_offset=offset)
259
+ states[d.isoformat()] = 'booked'
260
+ return {'status': 'booked', 'date': d.isoformat(), 'slot': 'first', 'dateStates': states, 'summary': f'{d.isoformat()} 预约成功', 'windowMode': 'available_days'}
261
+ except Exception as exc:
262
+ msg = str(exc)
263
+ if any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间')):
264
+ states[d.isoformat()] = 'selectable_no_slot'
265
+ else:
266
+ states[d.isoformat()] = 'selectable_booking_failed'
267
+ orch._log(f' ⚠️ {d} 预约失败: {msg[:160]}')
268
+ else:
269
+ targets = []
270
+ d = max(from_date, today + timedelta(days=1))
271
+ while d <= to_date:
272
+ targets.append(d); d += timedelta(days=1)
273
+ if not targets:
274
+ return {'status': 'failed', 'summary': '日期范围内没有可预约日期(今天及以前不算)'}
275
+ states = {d.isoformat(): 'pending' for d in targets}
276
+ for d in targets:
277
+ offset = (d - today).days
278
+ try:
279
+ await prepare_appointment_page(orch)
280
+ info = await find_date_cell(orch, d)
281
+ if not info.get('found'):
282
+ states[d.isoformat()] = 'date_not_found'; continue
283
+ if info.get('disabled'):
284
+ states[d.isoformat()] = 'disabled'; continue
285
+ except Exception as exc:
286
+ states[d.isoformat()] = 'page_error'
287
+ orch._log(f' ⚠️ {d} 分类失败: {str(exc)[:120]}'); continue
288
+ try:
289
+ await orch.browser.visit_plan_page('appointment-v2', orch.state.shipment_id, step=4)
290
+ await orch._book_appointment(hour_idx=0, date_offset=offset)
291
+ states[d.isoformat()] = 'booked'
292
+ return {'status': 'booked', 'date': d.isoformat(), 'slot': 'first', 'dateStates': states, 'summary': f'{d.isoformat()} 预约成功'}
293
+ except Exception as exc:
294
+ msg = str(exc)
295
+ if any(k in msg for k in ('可用时段不足', '未渲染出可选时段', '时间选择失败', '未写入日期时间')):
296
+ states[d.isoformat()] = 'selectable_no_slot'
297
+ else:
298
+ states[d.isoformat()] = 'selectable_booking_failed'
299
+ orch._log(f' ⚠️ {d} 预约失败: {msg[:160]}')
300
+ last_states = states
301
+ print(json.dumps({'event': 'cycle', 'cycle': cycle_no, 'dateStates': states}, ensure_ascii=False), flush=True)
302
+ if cycles and cycle_no >= cycles:
303
+ break
304
+ await asyncio.sleep(max(10, int(task.get('pollIntervalSec') or 60)))
305
+ return {'status': 'failed', 'dateStates': last_states, 'cycles': cycle_no, 'summary': '本轮日期范围内未抢到预约'}
306
+ finally:
307
+ try:
308
+ await orch.browser.close()
309
+ except Exception:
310
+ pass
311
+ try:
312
+ orch.release_store_executor_lock()
313
+ except Exception:
314
+ pass
315
+
316
+
317
+ def main():
318
+ task = load_task()
319
+ try:
320
+ out = asyncio.run(run_task(task))
321
+ except Exception as exc:
322
+ out = {'status': 'failed', 'summary': f'{type(exc).__name__}: {exc}'}
323
+ print(json.dumps(out, ensure_ascii=False), flush=True)
324
+
325
+
326
+ if __name__ == '__main__':
327
+ main()
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * P4 本机 runner(客户机侧)—— 后台 Pull 派发 + 租约 的执行端。
4
+ *
5
+ * 链路:
6
+ * ① 用 runner token 向数据中心长轮询领取作业(claim)
7
+ * ② 若本机 dsh web 在跑:通过其 task-board HTTP API(同机 loopback,无需 token)
8
+ * 建卡(pin job.preset)→ 运行 → 轮询直到执行结束
9
+ * ③ 把结果 complete 回数据中心(写 Run + 回写卡状态)
10
+ * · 执行期间每 30s 发一次心跳续租;租约超时则由服务端回收、别的 runner 可重领
11
+ *
12
+ * 用法:
13
+ * node scripts/argent-runner.mjs --config ~/.dsh/argent/runner.json # 常驻循环
14
+ * node scripts/argent-runner.mjs --config ... --once # 领一个作业就退出(验收/调试)
15
+ * node scripts/argent-runner.mjs --health # 只做连通性自检
16
+ *
17
+ * 配置文件(600,示例):
18
+ * {
19
+ * "serverUrl": "https://whyshu.com",
20
+ * "tenantAppId": "ly-0001",
21
+ * "runnerToken": "eyJ…",
22
+ * "dshWebUrl": "http://127.0.0.1:3080",
23
+ * "leaseSec": 90,
24
+ * "pollWaitMs": 20000,
25
+ * "execTimeoutSec": 900
26
+ * }
27
+ * ⛔ 不打印 token;⛔ 只允许对 dshWebUrl 的本机地址发请求(校验 host 为 loopback)。
28
+ */
29
+ import { readFileSync, mkdirSync, appendFileSync } from 'node:fs';
30
+ import { randomUUID } from 'node:crypto';
31
+ import { homedir, hostname } from 'node:os';
32
+ import { join } from 'node:path';
33
+
34
+ const argv = process.argv.slice(2);
35
+ const has = (f) => argv.includes(f);
36
+ const val = (f, d = '') => { const i = argv.indexOf(f); return i > -1 ? (argv[i + 1] ?? d) : d; };
37
+
38
+ const CONFIG_PATH = val('--config', join(homedir(), '.dsh', 'argent', 'runner.json'));
39
+ const ONCE = has('--once');
40
+ const HEALTH_ONLY = has('--health');
41
+ const DRY_RUN = has('--dry-run');
42
+ const LOG_DIR = join(homedir(), '.dsh', 'argent', 'runner');
43
+
44
+ function log(event, extra = {}) {
45
+ const line = JSON.stringify({ ts: new Date().toISOString(), event, ...extra });
46
+ console.log(line);
47
+ try { mkdirSync(LOG_DIR, { recursive: true }); appendFileSync(join(LOG_DIR, 'runner.ndjson'), line + '\n'); } catch { /* 日志失败不影响执行 */ }
48
+ }
49
+
50
+ function loadConfig() {
51
+ let cfg = {};
52
+ try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch (error) {
53
+ console.error(`❌ 读不到配置 ${CONFIG_PATH}: ${error.message}`);
54
+ process.exit(2);
55
+ }
56
+ const merged = {
57
+ serverUrl: String(process.env.ARGENT_SERVER_URL || cfg.serverUrl || '').replace(/\/+$/, ''),
58
+ tenantAppId: String(process.env.ARGENT_TENANT_APP_ID || cfg.tenantAppId || ''),
59
+ runnerToken: String(process.env.ARGENT_RUNNER_TOKEN || cfg.runnerToken || ''),
60
+ dshWebUrl: String(process.env.ARGENT_DSH_WEB_URL || cfg.dshWebUrl || 'http://127.0.0.1:3080').replace(/\/+$/, ''),
61
+ leaseSec: Number(cfg.leaseSec) || 90,
62
+ pollWaitMs: Number(cfg.pollWaitMs) || 20000,
63
+ execTimeoutSec: Number(cfg.execTimeoutSec) || 900,
64
+ runnerId: String(cfg.runnerId || `runner-${hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-')}`),
65
+ };
66
+ for (const k of ['serverUrl', 'tenantAppId', 'runnerToken']) {
67
+ if (!merged[k]) { console.error(`❌ 配置缺少 ${k}(${CONFIG_PATH})`); process.exit(2); }
68
+ }
69
+ const web = new URL(merged.dshWebUrl);
70
+ if (!['127.0.0.1', 'localhost', '::1'].includes(web.hostname)) {
71
+ console.error(`❌ dshWebUrl 必须是本机地址(当前 ${web.hostname})`); process.exit(2);
72
+ }
73
+ return merged;
74
+ }
75
+
76
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
77
+
78
+ function serverHeaders(cfg) {
79
+ return { authorization: `Bearer ${cfg.runnerToken}`, 'content-type': 'application/json' };
80
+ }
81
+
82
+ async function serverCall(cfg, path, { method = 'GET', body } = {}) {
83
+ const res = await fetch(`${cfg.serverUrl}/api/argent/v1/tenants/${cfg.tenantAppId}${path}`, {
84
+ method, headers: serverHeaders(cfg), body: body === undefined ? undefined : JSON.stringify(body),
85
+ });
86
+ const text = await res.text();
87
+ let data = null;
88
+ try { data = JSON.parse(text); } catch { data = text; }
89
+ if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${data?.error?.code || ''} ${data?.error?.message || ''}`.trim());
90
+ return data;
91
+ }
92
+
93
+ /** task-board 同机 HTTP API(loopback + same-origin marker;不需要 dsh web token) */
94
+ async function tbAction(cfg, action) {
95
+ const res = await fetch(`${cfg.dshWebUrl}/api/task-board/action`, {
96
+ method: 'POST',
97
+ headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
98
+ body: JSON.stringify({ requestId: randomUUID(), action }),
99
+ });
100
+ const text = await res.text();
101
+ let data = null;
102
+ try { data = JSON.parse(text); } catch { data = text; }
103
+ return { status: res.status, data };
104
+ }
105
+
106
+ async function tbState(cfg) {
107
+ const res = await fetch(`${cfg.dshWebUrl}/api/task-board/state`, { headers: { 'sec-fetch-site': 'same-origin' } });
108
+ if (!res.ok) throw new Error(`task-board state → ${res.status}`);
109
+ return res.json();
110
+ }
111
+
112
+ /** 执行一个作业:建卡 → 运行 → 等待结算;返回 { outcome, summary, sessionRef } */
113
+ async function executeJob(cfg, job) {
114
+ const taskId = randomUUID();
115
+ const create = await tbAction(cfg, {
116
+ kind: 'create', id: taskId,
117
+ input: {
118
+ title: job.title || `后台派发:${job.preset}`,
119
+ description: String(job.prompt || '').slice(0, 500),
120
+ prompt: job.prompt,
121
+ mode: job.preset,
122
+ },
123
+ });
124
+ if (create.status !== 200) {
125
+ return { outcome: 'failed', summary: `task-board create 失败:HTTP ${create.status} ${JSON.stringify(create.data).slice(0, 300)}` };
126
+ }
127
+ log('job.task.created', { jobId: String(job._id), taskId, preset: job.preset });
128
+
129
+ const run = await tbAction(cfg, { kind: 'run', taskId });
130
+ if (run.status !== 200) {
131
+ return { outcome: 'failed', summary: `task-board run 失败:HTTP ${run.status} ${JSON.stringify(run.data).slice(0, 300)}`, taskId };
132
+ }
133
+ log('job.task.running', { jobId: String(job._id), taskId });
134
+
135
+ const deadline = Date.now() + cfg.execTimeoutSec * 1000;
136
+ while (Date.now() < deadline) {
137
+ await sleep(2000);
138
+ const state = await tbState(cfg);
139
+ const task = (state.tasks || []).find((t) => t.id === taskId);
140
+ if (!task) continue;
141
+ const execs = task.executions || [];
142
+ const latest = execs[execs.length - 1];
143
+ if (latest && latest.endedAt) {
144
+ const outcome = latest.result === 'succeeded' ? 'succeeded' : 'failed';
145
+ return { outcome, summary: latest.error ? String(latest.error).slice(0, 500) : '', sessionRef: latest.sessionId || '', taskId };
146
+ }
147
+ }
148
+ return { outcome: 'failed', summary: `执行超时(>${cfg.execTimeoutSec}s)`, taskId };
149
+ }
150
+
151
+ async function runOnce(cfg) {
152
+ 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 };
155
+ 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 }; }
157
+
158
+ // 心跳续租(执行期间每 30s)
159
+ let heartbeatTimer = null;
160
+ const startHeartbeat = () => {
161
+ heartbeatTimer = setInterval(async () => {
162
+ try {
163
+ await serverCall(cfg, `/jobs/${job._id}/heartbeat`, { method: 'POST', body: { runnerId: cfg.runnerId, leaseSec: cfg.leaseSec } });
164
+ log('job.heartbeat', { jobId: String(job._id) });
165
+ } catch (error) {
166
+ log('job.heartbeat.error', { jobId: String(job._id), message: error.message });
167
+ }
168
+ }, 30_000);
169
+ if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
170
+ };
171
+
172
+ startHeartbeat();
173
+ let result;
174
+ try {
175
+ result = await executeJob(cfg, job);
176
+ } finally {
177
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
178
+ }
179
+
180
+ try {
181
+ const done = await serverCall(cfg, `/jobs/${job._id}/complete`, {
182
+ method: 'POST',
183
+ body: {
184
+ runnerId: cfg.runnerId, outcome: result.outcome,
185
+ summary: result.summary || '', sessionRef: result.sessionRef || '',
186
+ },
187
+ });
188
+ log('job.completed', { jobId: String(job._id), outcome: result.outcome, runId: done?.data?.run?._id || null, taskId: result.taskId || null });
189
+ return { claimed: true, outcome: result.outcome };
190
+ } catch (error) {
191
+ log('job.complete.error', { jobId: String(job._id), message: error.message });
192
+ return { claimed: true, outcome: result.outcome, completeError: error.message };
193
+ }
194
+ }
195
+
196
+ async function health(cfg) {
197
+ const out = { server: false, dshWeb: false, tenantAppId: cfg.tenantAppId, runnerId: cfg.runnerId };
198
+ try {
199
+ const res = await fetch(`${cfg.serverUrl}/api/argent/v1/health`);
200
+ out.server = res.ok;
201
+ } catch { /* noop */ }
202
+ try {
203
+ const state = await tbState(cfg);
204
+ out.dshWeb = true;
205
+ out.boardRevision = state.revision;
206
+ } catch { /* noop */ }
207
+ console.log(JSON.stringify(out));
208
+ return out;
209
+ }
210
+
211
+ async function main() {
212
+ const cfg = loadConfig();
213
+ log('runner.start', { runnerId: cfg.runnerId, tenantAppId: cfg.tenantAppId, server: new URL(cfg.serverUrl).origin, dshWeb: new URL(cfg.dshWebUrl).origin, once: ONCE });
214
+ if (HEALTH_ONLY) { const h = await health(cfg); process.exit(h.server && h.dshWeb ? 0 : 1); }
215
+ let stopping = false;
216
+ process.on('SIGINT', () => { stopping = true; log('runner.stop', {}); process.exit(0); });
217
+ 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);
221
+ }
222
+
223
+ main().catch((error) => { console.error(`❌ runner 失败: ${error.message}`); process.exit(1); });
@@ -127,7 +127,7 @@ export function buildChecks({ engine, dataDir, state, scopeDirs = [], stateOnly
127
127
  : (has
128
128
  ? `状态文件在但**形状不认识**(${engine.stateFile(dataDir)})——版本或字段对不上这份契约`
129
129
  : `证据不足:没有状态文件(${engine.stateFile(dataDir)})⇒ 这份安装没跑过安装后 setup`),
130
- fix: state ? [] : ['重启宿主(插件启动期会写一次)', '或重跑安装器:node install-argent.mjs'],
130
+ fix: state ? [] : ['重启宿主(插件启动期会写一次)', '或运行启动器自检(@whyshu/argent 的自检子命令)修复'],
131
131
  })
132
132
  }
133
133
 
@@ -140,7 +140,7 @@ export function buildChecks({ engine, dataDir, state, scopeDirs = [], stateOnly
140
140
  const step = state && state.steps ? state.steps['python-runtime'] : null
141
141
  if (verdict) {
142
142
  if (verdict.state === 'ready') {
143
- checks.push({ id: 'python-runtime', ok: true, detail: `venv 可跑(${venvPython};${engine.VENV_PKGS.join(' / ')} 可 import)`, fix: [] })
143
+ checks.push({ id: 'python-runtime', ok: true, detail: `venv 可跑(${venvPython};${(engine.VENV_IMPORT_PKGS || engine.VENV_PKGS).join(' / ')} 可 import)`, fix: [] })
144
144
  } else if (verdict.state === 'timeout') {
145
145
  checks.push({
146
146
  id: 'python-runtime',
@@ -157,7 +157,7 @@ export function buildChecks({ engine, dataDir, state, scopeDirs = [], stateOnly
157
157
  id: 'python-runtime',
158
158
  ok: null,
159
159
  detail: `证据不足:${verdict.reason}${ver}(${at}),且没有状态文件 ⇒ 无法判断这份安装是否该有它`,
160
- fix: ['重跑安装器补齐运行时:node install-argent.mjs --venv-only', '或重启宿主让插件后台补建'],
160
+ fix: ['运行 `bash scripts/python-runtime.sh ensure` 补齐运行时', '或重启宿主让插件后台补建'],
161
161
  })
162
162
  } else {
163
163
  const repair = step && step.repair && Array.isArray(step.repair.lines) ? step.repair.lines : engine.repairHint({ dataDir }).lines