@reconcrap/boss-recommend-mcp 2.0.35 → 2.0.37

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.
File without changes
@@ -1,4 +1,4 @@
1
- {
1
+ {
2
2
  "baseUrl": "https://api.openai.com/v1",
3
3
  "apiKey": "replace-with-openai-api-key",
4
4
  "model": "gpt-4.1-mini",
package/package.json CHANGED
@@ -1,119 +1,119 @@
1
- {
2
- "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.35",
4
- "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
- "keywords": [
6
- "boss",
7
- "mcp",
8
- "codex",
9
- "recruiting",
10
- "boss-zhipin",
11
- "recommend"
12
- ],
13
- "type": "module",
14
- "main": "src/index.js",
15
- "bin": {
16
- "boss-recommend-mcp": "bin/boss-recommend-mcp.js"
17
- },
18
- "scripts": {
19
- "start": "node src/index.js",
20
- "cli": "node src/cli.js",
21
- "install:local": "node src/cli.js install",
22
- "postinstall": "node scripts/postinstall.cjs",
23
- "test:parser": "node src/test-parser.js",
24
- "test:run-state": "node src/test-run-state.js",
25
- "test:cdp-browser": "node src/test-cdp-browser.js",
26
- "test:core-capture": "node src/test-core-capture.js",
27
- "test:core-cv-capture-target": "node src/test-core-cv-capture-target.js",
28
- "test:core-cv-acquisition": "node src/test-core-cv-acquisition.js",
29
- "test:core-greet-quota": "node src/test-core-greet-quota.js",
30
- "test:core-infinite-list": "node src/test-core-infinite-list.js",
31
- "test:core-reporting": "node src/test-core-reporting.js",
32
- "test:core-run": "node src/test-core-run.js",
33
- "test:core-screening": "node src/test-core-screening.js",
34
- "test:core-self-heal": "node src/test-core-self-heal.js",
35
- "test:installer-migration": "node src/test-installer-migration.js",
36
- "test:recommend-actions": "node src/test-recommend-actions.js",
37
- "test:recommend-domain": "node src/test-recommend-domain.js",
38
- "test:recommend-run-service": "node src/test-recommend-run-service.js",
39
- "test:recommend-mcp": "node src/test-recommend-mcp.js",
40
- "test:recruit-domain": "node src/test-recruit-domain.js",
41
- "test:recruit-mcp": "node src/test-recruit-mcp.js",
42
- "test:chat-domain": "node src/test-chat-domain.js",
43
- "test:chat-run-service": "node src/test-chat-run-service.js",
44
- "test:chat-mcp": "node src/test-chat-mcp.js",
45
- "test:async": "node src/test-index-async.js",
46
- "test:runtime-scan": "node src/test-runtime-scan.js",
47
- "scan:runtime": "node scripts/scan-forbidden-runtime.js",
48
- "scan:runtime:json": "node scripts/scan-forbidden-runtime.js --json",
49
- "scan:runtime:strict": "node scripts/scan-forbidden-runtime.js --fail-on-findings",
50
- "scan:runtime:package": "node scripts/scan-forbidden-runtime.js --package-surface",
51
- "scan:runtime:package:strict": "node scripts/scan-forbidden-runtime.js --package-surface --fail-on-legacy",
52
- "scan:legacy-boundary": "node scripts/scan-legacy-boundary.js",
53
- "scan:package-boundary": "node scripts/scan-package-boundary.js",
54
- "gate:phase9-static": "node scripts/phase9-static-gate.js",
55
- "gate:phase10-complete": "node scripts/phase10-completion-gate.js",
56
- "live:cdp-smoke": "node scripts/live-cdp-smoke.js",
57
- "live:run-lifecycle": "node scripts/live-run-lifecycle-smoke.js",
58
- "live:screening": "node scripts/live-screening-smoke.js",
59
- "live:detail": "node scripts/live-detail-smoke.js",
60
- "live:infinite-list": "node scripts/live-infinite-list-smoke.js",
61
- "live:scroll-end": "node scripts/live-scroll-end-screenshot.js",
62
- "live:refresh-round": "node scripts/live-refresh-round-smoke.js",
63
- "live:self-heal": "node scripts/live-self-heal-smoke.js",
64
- "live:recommend-actions": "node scripts/live-recommend-actions-smoke.js",
65
- "live:recommend-phase10-full": "node scripts/live-recommend-phase10-full.js",
66
- "live:recommend-domain": "node scripts/live-recommend-domain-smoke.js",
67
- "live:recommend-run-service": "node scripts/live-recommend-run-service-smoke.js",
68
- "live:recommend-mcp": "node scripts/live-recommend-mcp-smoke.js",
69
- "live:search-phase10-full": "node scripts/live-search-phase10-full.js",
70
- "live:recruit-domain": "node scripts/live-recruit-domain-smoke.js",
71
- "live:recruit-run-service": "node scripts/live-recruit-run-service-smoke.js",
72
- "live:recruit-mcp": "node scripts/live-recruit-mcp-smoke.js",
73
- "live:chat-domain": "node scripts/live-chat-domain-smoke.js",
74
- "live:chat-run-service": "node scripts/live-chat-run-service-smoke.js",
75
- "live:chat-mcp": "node scripts/live-chat-mcp-smoke.js",
76
- "live:cv-capture-target": "node scripts/live-cv-capture-target-smoke.js",
77
- "live:chat-phase10-full": "node scripts/live-chat-phase10-full.js",
78
- "live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js"
79
- },
80
- "files": [
81
- "bin",
82
- "config/screening-config.example.json",
83
- "skills",
84
- "scripts/postinstall.cjs",
85
- "src/core",
86
- "src/domains",
87
- "src/chat-mcp.js",
88
- "src/chat-runtime-config.js",
89
- "src/cli.js",
90
- "src/index.js",
91
- "src/parser.js",
92
- "src/recommend-mcp.js",
93
- "src/recruit-mcp.js",
94
- "src/run-state.js",
95
- "README.md"
96
- ],
97
- "dependencies": {
98
- "chrome-remote-interface": "^0.33.3",
99
- "sharp": "^0.34.4",
100
- "ws": "^8.19.0"
101
- },
102
- "engines": {
103
- "node": ">=18"
104
- },
105
- "publishConfig": {
106
- "access": "public"
107
- },
108
- "license": "MIT",
109
- "devDependencies": {},
110
- "repository": {
111
- "type": "git",
112
- "url": "git+https://github.com/reconcrap-cpu/boss-recommend-mcp.git"
113
- },
114
- "author": "",
115
- "bugs": {
116
- "url": "https://github.com/reconcrap-cpu/boss-recommend-mcp/issues"
117
- },
118
- "homepage": "https://github.com/reconcrap-cpu/boss-recommend-mcp#readme"
119
- }
1
+ {
2
+ "name": "@reconcrap/boss-recommend-mcp",
3
+ "version": "2.0.37",
4
+ "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
+ "keywords": [
6
+ "boss",
7
+ "mcp",
8
+ "codex",
9
+ "recruiting",
10
+ "boss-zhipin",
11
+ "recommend"
12
+ ],
13
+ "type": "module",
14
+ "main": "src/index.js",
15
+ "bin": {
16
+ "boss-recommend-mcp": "bin/boss-recommend-mcp.js"
17
+ },
18
+ "scripts": {
19
+ "start": "node src/index.js",
20
+ "cli": "node src/cli.js",
21
+ "install:local": "node src/cli.js install",
22
+ "postinstall": "node scripts/postinstall.cjs",
23
+ "test:parser": "node src/test-parser.js",
24
+ "test:run-state": "node src/test-run-state.js",
25
+ "test:cdp-browser": "node src/test-cdp-browser.js",
26
+ "test:core-capture": "node src/test-core-capture.js",
27
+ "test:core-cv-capture-target": "node src/test-core-cv-capture-target.js",
28
+ "test:core-cv-acquisition": "node src/test-core-cv-acquisition.js",
29
+ "test:core-greet-quota": "node src/test-core-greet-quota.js",
30
+ "test:core-infinite-list": "node src/test-core-infinite-list.js",
31
+ "test:core-reporting": "node src/test-core-reporting.js",
32
+ "test:core-run": "node src/test-core-run.js",
33
+ "test:core-screening": "node src/test-core-screening.js",
34
+ "test:core-self-heal": "node src/test-core-self-heal.js",
35
+ "test:installer-migration": "node src/test-installer-migration.js",
36
+ "test:recommend-actions": "node src/test-recommend-actions.js",
37
+ "test:recommend-domain": "node src/test-recommend-domain.js",
38
+ "test:recommend-run-service": "node src/test-recommend-run-service.js",
39
+ "test:recommend-mcp": "node src/test-recommend-mcp.js",
40
+ "test:recruit-domain": "node src/test-recruit-domain.js",
41
+ "test:recruit-mcp": "node src/test-recruit-mcp.js",
42
+ "test:chat-domain": "node src/test-chat-domain.js",
43
+ "test:chat-run-service": "node src/test-chat-run-service.js",
44
+ "test:chat-mcp": "node src/test-chat-mcp.js",
45
+ "test:async": "node src/test-index-async.js",
46
+ "test:runtime-scan": "node src/test-runtime-scan.js",
47
+ "scan:runtime": "node scripts/scan-forbidden-runtime.js",
48
+ "scan:runtime:json": "node scripts/scan-forbidden-runtime.js --json",
49
+ "scan:runtime:strict": "node scripts/scan-forbidden-runtime.js --fail-on-findings",
50
+ "scan:runtime:package": "node scripts/scan-forbidden-runtime.js --package-surface",
51
+ "scan:runtime:package:strict": "node scripts/scan-forbidden-runtime.js --package-surface --fail-on-legacy",
52
+ "scan:legacy-boundary": "node scripts/scan-legacy-boundary.js",
53
+ "scan:package-boundary": "node scripts/scan-package-boundary.js",
54
+ "gate:phase9-static": "node scripts/phase9-static-gate.js",
55
+ "gate:phase10-complete": "node scripts/phase10-completion-gate.js",
56
+ "live:cdp-smoke": "node scripts/live-cdp-smoke.js",
57
+ "live:run-lifecycle": "node scripts/live-run-lifecycle-smoke.js",
58
+ "live:screening": "node scripts/live-screening-smoke.js",
59
+ "live:detail": "node scripts/live-detail-smoke.js",
60
+ "live:infinite-list": "node scripts/live-infinite-list-smoke.js",
61
+ "live:scroll-end": "node scripts/live-scroll-end-screenshot.js",
62
+ "live:refresh-round": "node scripts/live-refresh-round-smoke.js",
63
+ "live:self-heal": "node scripts/live-self-heal-smoke.js",
64
+ "live:recommend-actions": "node scripts/live-recommend-actions-smoke.js",
65
+ "live:recommend-phase10-full": "node scripts/live-recommend-phase10-full.js",
66
+ "live:recommend-domain": "node scripts/live-recommend-domain-smoke.js",
67
+ "live:recommend-run-service": "node scripts/live-recommend-run-service-smoke.js",
68
+ "live:recommend-mcp": "node scripts/live-recommend-mcp-smoke.js",
69
+ "live:search-phase10-full": "node scripts/live-search-phase10-full.js",
70
+ "live:recruit-domain": "node scripts/live-recruit-domain-smoke.js",
71
+ "live:recruit-run-service": "node scripts/live-recruit-run-service-smoke.js",
72
+ "live:recruit-mcp": "node scripts/live-recruit-mcp-smoke.js",
73
+ "live:chat-domain": "node scripts/live-chat-domain-smoke.js",
74
+ "live:chat-run-service": "node scripts/live-chat-run-service-smoke.js",
75
+ "live:chat-mcp": "node scripts/live-chat-mcp-smoke.js",
76
+ "live:cv-capture-target": "node scripts/live-cv-capture-target-smoke.js",
77
+ "live:chat-phase10-full": "node scripts/live-chat-phase10-full.js",
78
+ "live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js"
79
+ },
80
+ "files": [
81
+ "bin",
82
+ "config/screening-config.example.json",
83
+ "skills",
84
+ "scripts/postinstall.cjs",
85
+ "src/core",
86
+ "src/domains",
87
+ "src/chat-mcp.js",
88
+ "src/chat-runtime-config.js",
89
+ "src/cli.js",
90
+ "src/index.js",
91
+ "src/parser.js",
92
+ "src/recommend-mcp.js",
93
+ "src/recruit-mcp.js",
94
+ "src/run-state.js",
95
+ "README.md"
96
+ ],
97
+ "dependencies": {
98
+ "chrome-remote-interface": "^0.33.3",
99
+ "sharp": "^0.34.4",
100
+ "ws": "^8.19.0"
101
+ },
102
+ "engines": {
103
+ "node": ">=18"
104
+ },
105
+ "publishConfig": {
106
+ "access": "public"
107
+ },
108
+ "license": "MIT",
109
+ "devDependencies": {},
110
+ "repository": {
111
+ "type": "git",
112
+ "url": "git+https://github.com/reconcrap-cpu/boss-recommend-mcp.git"
113
+ },
114
+ "author": "",
115
+ "bugs": {
116
+ "url": "https://github.com/reconcrap-cpu/boss-recommend-mcp/issues"
117
+ },
118
+ "homepage": "https://github.com/reconcrap-cpu/boss-recommend-mcp#readme"
119
+ }
@@ -47,6 +47,7 @@ function snapshotFromEntry(entry) {
47
47
  return clone({
48
48
  runId: run.runId,
49
49
  name: run.name,
50
+ pid: run.pid,
50
51
  status: run.status,
51
52
  phase: run.phase,
52
53
  progress: run.progress,
@@ -205,11 +206,14 @@ export function createRunLifecycleManager({
205
206
  }
206
207
  }
207
208
 
208
- function startRun({ name, context = {}, progress = {}, checkpoint = {}, task }) {
209
+ function startRun({ runId: requestedRunId = "", name, pid = process.pid, context = {}, progress = {}, checkpoint = {}, task }) {
209
210
  if (typeof task !== "function") {
210
211
  throw new Error("startRun requires a task function");
211
212
  }
212
- const runId = createRunId(idPrefix);
213
+ const runId = String(requestedRunId || "").trim() || createRunId(idPrefix);
214
+ if (runs.has(runId)) {
215
+ throw new Error(`Run already exists: ${runId}`);
216
+ }
213
217
  const startedAt = now();
214
218
  const entry = {
215
219
  controller: new AbortController(),
@@ -219,6 +223,7 @@ export function createRunLifecycleManager({
219
223
  run: {
220
224
  runId,
221
225
  name: name || runId,
226
+ pid: Number.isInteger(pid) && pid > 0 ? pid : process.pid,
222
227
  status: RUN_STATUS_QUEUED,
223
228
  phase: "queued",
224
229
  progress,
@@ -88,6 +88,105 @@ export function buildRecommendFilterSelectionOptions(filter = {}, {
88
88
  };
89
89
  }
90
90
 
91
+ function refreshFailureReason(method = "") {
92
+ return method === "page_navigate" ? "page_navigate_failed" : "page_reload_failed";
93
+ }
94
+
95
+ async function applyRefreshMethod(client, method, {
96
+ jobLabel = "",
97
+ pageScope = "recommend",
98
+ fallbackPageScope = "recommend",
99
+ filter = {},
100
+ targetUrl = RECOMMEND_TARGET_URL,
101
+ forceRecentNotView = true,
102
+ cardTimeoutMs = 30000,
103
+ reloadSettleMs = 8000
104
+ } = {}) {
105
+ const started = Date.now();
106
+ let currentRootState = null;
107
+ let jobSelection = null;
108
+ let pageScopeResult = null;
109
+ let filterResult = null;
110
+ try {
111
+ if (method === "page_navigate") {
112
+ await client.Page.navigate({ url: targetUrl || RECOMMEND_TARGET_URL });
113
+ } else {
114
+ await client.Page.reload({ ignoreCache: true });
115
+ }
116
+ if (reloadSettleMs > 0) await sleep(reloadSettleMs);
117
+ currentRootState = await waitForRecommendRoots(client, {
118
+ timeoutMs: Math.max(45000, reloadSettleMs * 6),
119
+ intervalMs: 500
120
+ });
121
+ if (!currentRootState?.iframe?.documentNodeId) {
122
+ throw new Error("Recommend iframe was not ready after refresh reload");
123
+ }
124
+ if (jobLabel) {
125
+ jobSelection = await selectRecommendJob(client, currentRootState.iframe.documentNodeId, {
126
+ jobLabel,
127
+ settleMs: reloadSettleMs > 10000 ? 12000 : 6000
128
+ });
129
+ if (!jobSelection.selected) {
130
+ throw new Error(`Requested recommend job was not selected after refresh reload: ${jobSelection.reason}`);
131
+ }
132
+ currentRootState = await getRecommendRoots(client);
133
+ }
134
+ pageScopeResult = await selectRecommendPageScope(
135
+ client,
136
+ currentRootState.iframe.documentNodeId,
137
+ {
138
+ pageScope,
139
+ fallbackScope: fallbackPageScope,
140
+ settleMs: reloadSettleMs > 10000 ? 3000 : 1200,
141
+ timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
142
+ }
143
+ );
144
+ if (!pageScopeResult.selected) {
145
+ throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
146
+ }
147
+ currentRootState = await getRecommendRoots(client);
148
+ filterResult = await selectAndConfirmFirstSafeFilter(
149
+ client,
150
+ currentRootState.iframe.documentNodeId,
151
+ buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
152
+ );
153
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
154
+ timeoutMs: cardTimeoutMs,
155
+ intervalMs: 500
156
+ });
157
+ if (!cardNodeIds.length) {
158
+ throw new Error("No recommend candidate cards were found after refresh reload");
159
+ }
160
+ return {
161
+ ok: true,
162
+ method,
163
+ target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
164
+ job_selection: jobSelection,
165
+ page_scope: pageScopeResult,
166
+ filter: filterResult,
167
+ card_count: cardNodeIds.length,
168
+ root_state: currentRootState,
169
+ forced_recent_not_view: forceRecentNotView,
170
+ elapsed_ms: Date.now() - started
171
+ };
172
+ } catch (error) {
173
+ return {
174
+ ok: false,
175
+ method,
176
+ reason: refreshFailureReason(method),
177
+ error: error?.message || String(error),
178
+ target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
179
+ job_selection: jobSelection,
180
+ page_scope: pageScopeResult,
181
+ filter: filterResult,
182
+ card_count: 0,
183
+ root_state: currentRootState,
184
+ forced_recent_not_view: forceRecentNotView,
185
+ elapsed_ms: Date.now() - started
186
+ };
187
+ }
188
+ }
189
+
91
190
  export async function refreshRecommendListAtEnd(client, {
92
191
  rootState = null,
93
192
  jobLabel = "",
@@ -103,9 +202,10 @@ export async function refreshRecommendListAtEnd(client, {
103
202
  reloadSettleMs = 8000
104
203
  } = {}) {
105
204
  const attempts = [];
106
- let currentRootState = rootState || await getRecommendRoots(client);
205
+ let currentRootState = rootState || null;
107
206
 
108
207
  if (preferEndRefreshButton) {
208
+ currentRootState = currentRootState || await getRecommendRoots(client);
109
209
  const buttonResult = await clickRecommendEndRefreshButton(
110
210
  client,
111
211
  currentRootState.iframe.documentNodeId,
@@ -159,82 +259,49 @@ export async function refreshRecommendListAtEnd(client, {
159
259
  }
160
260
  }
161
261
 
162
- let fallbackMethod = "page_reload";
163
- try {
164
- let method = "page_reload";
165
- if (forceNavigate && typeof client?.Page?.navigate === "function") {
166
- await client.Page.navigate({ url: targetUrl || RECOMMEND_TARGET_URL });
167
- method = "page_navigate";
168
- fallbackMethod = method;
169
- } else {
170
- await client.Page.reload({ ignoreCache: true });
171
- fallbackMethod = method;
172
- }
173
- if (reloadSettleMs > 0) await sleep(reloadSettleMs);
174
- currentRootState = await waitForRecommendRoots(client, {
175
- timeoutMs: Math.max(30000, reloadSettleMs * 4),
176
- intervalMs: 500
262
+ const fallbackMethods = [];
263
+ if (forceNavigate && typeof client?.Page?.navigate === "function") {
264
+ fallbackMethods.push("page_navigate");
265
+ }
266
+ if (typeof client?.Page?.reload === "function") {
267
+ fallbackMethods.push("page_reload");
268
+ }
269
+ if (!fallbackMethods.length) {
270
+ fallbackMethods.push("page_reload");
271
+ }
272
+
273
+ let lastRefreshResult = null;
274
+ for (const method of fallbackMethods) {
275
+ const refreshResult = await applyRefreshMethod(client, method, {
276
+ jobLabel,
277
+ pageScope,
278
+ fallbackPageScope,
279
+ filter,
280
+ targetUrl,
281
+ forceRecentNotView,
282
+ cardTimeoutMs,
283
+ reloadSettleMs
177
284
  });
178
- if (!currentRootState?.iframe?.documentNodeId) {
179
- throw new Error("Recommend iframe was not ready after refresh reload");
180
- }
181
- let jobSelection = null;
182
- if (jobLabel) {
183
- jobSelection = await selectRecommendJob(client, currentRootState.iframe.documentNodeId, {
184
- jobLabel,
185
- settleMs: reloadSettleMs > 10000 ? 12000 : 6000
186
- });
187
- if (!jobSelection.selected) {
188
- throw new Error(`Requested recommend job was not selected after refresh reload: ${jobSelection.reason}`);
189
- }
190
- currentRootState = await getRecommendRoots(client);
285
+ if (refreshResult.ok) {
286
+ return {
287
+ ...refreshResult,
288
+ attempts
289
+ };
191
290
  }
192
- const pageScopeResult = await selectRecommendPageScope(
193
- client,
194
- currentRootState.iframe.documentNodeId,
195
- {
196
- pageScope,
197
- fallbackScope: fallbackPageScope,
198
- settleMs: reloadSettleMs > 10000 ? 3000 : 1200,
199
- timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
200
- }
201
- );
202
- if (!pageScopeResult.selected) {
203
- throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
204
- }
205
- currentRootState = await getRecommendRoots(client);
206
- const filterResult = await selectAndConfirmFirstSafeFilter(
207
- client,
208
- currentRootState.iframe.documentNodeId,
209
- buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
210
- );
211
- const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
212
- timeoutMs: cardTimeoutMs,
213
- intervalMs: 500
214
- });
215
- return {
216
- ok: cardNodeIds.length > 0,
217
- method,
218
- attempts,
219
- target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
220
- job_selection: jobSelection,
221
- page_scope: pageScopeResult,
222
- filter: filterResult,
223
- card_count: cardNodeIds.length,
224
- root_state: currentRootState,
225
- forced_recent_not_view: forceRecentNotView
226
- };
227
- } catch (error) {
228
- return {
291
+ attempts.push(refreshResult);
292
+ lastRefreshResult = refreshResult;
293
+ }
294
+
295
+ return {
296
+ ...(lastRefreshResult || {
229
297
  ok: false,
230
- method: fallbackMethod,
231
- reason: fallbackMethod === "page_navigate" ? "page_navigate_failed" : "page_reload_failed",
232
- error: error?.message || String(error),
233
- attempts,
234
- target_url: fallbackMethod === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
298
+ method: fallbackMethods[fallbackMethods.length - 1] || "page_reload",
299
+ reason: "refresh_failed",
300
+ error: "Recommend refresh did not run",
235
301
  card_count: 0,
236
302
  root_state: currentRootState,
237
303
  forced_recent_not_view: forceRecentNotView
238
- };
239
- }
304
+ }),
305
+ attempts
306
+ };
240
307
  }
@@ -355,16 +355,22 @@ function compactRefreshAttempt(refreshAttempt) {
355
355
  return {
356
356
  ok: Boolean(refreshAttempt.ok),
357
357
  method: refreshAttempt.method || "",
358
+ reason: refreshAttempt.reason || null,
359
+ error: refreshAttempt.error || null,
358
360
  forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
359
361
  target_url: refreshAttempt.target_url || null,
360
362
  card_count: refreshAttempt.card_count || 0,
363
+ elapsed_ms: refreshAttempt.elapsed_ms || 0,
361
364
  attempts: (refreshAttempt.attempts || []).map((attempt) => ({
362
365
  ok: Boolean(attempt.ok),
363
366
  method: attempt.method || "",
364
367
  reason: attempt.reason || null,
368
+ error: attempt.error || null,
365
369
  label: attempt.label || null,
366
370
  before_card_count: attempt.before_card_count || 0,
367
- after_card_count: attempt.after_card_count || 0
371
+ after_card_count: attempt.after_card_count || 0,
372
+ card_count: attempt.card_count || 0,
373
+ elapsed_ms: attempt.elapsed_ms || 0
368
374
  })),
369
375
  job_selection: compactJobSelection(refreshAttempt.job_selection),
370
376
  page_scope: compactPageScopeSelection(refreshAttempt.page_scope),
@@ -1260,6 +1266,8 @@ export function createRecommendRunService({
1260
1266
  const manager = lifecycle || createRunLifecycleManager({ idPrefix, onSnapshot });
1261
1267
 
1262
1268
  function startRecommendRun({
1269
+ runId = "",
1270
+ pid = process.pid,
1263
1271
  client,
1264
1272
  targetUrl = "",
1265
1273
  criteria = "",
@@ -1307,7 +1315,9 @@ export function createRecommendRunService({
1307
1315
  const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
1308
1316
  const normalizedDetailLimit = detailLimit == null ? null : Math.max(0, Number(detailLimit) || 0);
1309
1317
  return manager.startRun({
1318
+ runId,
1310
1319
  name,
1320
+ pid,
1311
1321
  context: {
1312
1322
  domain: "recommend",
1313
1323
  target_url: targetUrl,
package/src/index.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  getRecommendPipelineRunTool,
47
47
  listRecommendJobsTool,
48
48
  pauseRecommendPipelineRunTool,
49
+ prepareRecommendPipelineRunTool,
49
50
  resumeRecommendPipelineRunTool,
50
51
  startRecommendPipelineRunTool
51
52
  } from "./recommend-mcp.js";
@@ -116,6 +117,16 @@ const FRAMING_LINE = "line";
116
117
  const DETACHED_WORKER_FLAG = "--detached-worker";
117
118
  const DETACHED_WORKER_RUN_ID_FLAG = "--run-id";
118
119
  const DETACHED_WORKER_RESUME_FLAG = "--resume";
120
+ const AGENT_RUNTIME_HINT_KEYS = [
121
+ "CODEX_CI",
122
+ "CODEX_THREAD_ID",
123
+ "CODEX_HOME",
124
+ "OPENCLAW_HOME",
125
+ "OPENCLAW",
126
+ "TRAE_CN",
127
+ "TRAE_HOME",
128
+ "TRAE_AGENT"
129
+ ];
119
130
  const featuredCalibrationUnsupportedCode = "FEATURED_CALIBRATION_UNSUPPORTED_CDP_ONLY";
120
131
  const recommendSelfHealApplyUnsupportedCode = "RECOMMEND_SELF_HEAL_APPLY_UNSUPPORTED_CDP_ONLY";
121
132
  const detachedLegacyPipelineUnsupportedCode = "DETACHED_LEGACY_PIPELINE_UNSUPPORTED_CDP_ONLY";
@@ -138,6 +149,31 @@ function normalizeText(value) {
138
149
  return String(value || "").replace(/\s+/g, " ").trim();
139
150
  }
140
151
 
152
+ function clonePlain(value, fallback = null) {
153
+ try {
154
+ return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
155
+ } catch {
156
+ return fallback;
157
+ }
158
+ }
159
+
160
+ function isLikelyAgentRuntime() {
161
+ for (const key of AGENT_RUNTIME_HINT_KEYS) {
162
+ if (normalizeText(process.env[key] || "")) return true;
163
+ }
164
+ const originHints = [
165
+ normalizeText(process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || ""),
166
+ normalizeText(process.env.TERM_PROGRAM || "")
167
+ ].join(" ").toLowerCase();
168
+ return /codex|openclaw|trae/.test(originHints);
169
+ }
170
+
171
+ function shouldStartRecommendDetached() {
172
+ if (normalizeText(process.env.BOSS_RECOMMEND_CDP_INPROC || "") === "1") return false;
173
+ if (normalizeText(process.env.BOSS_RECOMMEND_CDP_DETACHED || "") === "1") return true;
174
+ return isLikelyAgentRuntime();
175
+ }
176
+
141
177
  function isUnlimitedTargetCountToken(value) {
142
178
  const token = normalizeText(value).toLowerCase();
143
179
  if (!token) return false;
@@ -255,17 +291,65 @@ function getDefaultAcceptedMessage(args = {}) {
255
291
  return `异步流水线已启动(detached)。默认不自动轮询;如需进度请按需调用 get_recommend_pipeline_run(建议至少每 ${recommendedMinutes} 分钟查询一次)。`;
256
292
  }
257
293
 
258
- function getRunArtifacts(runId) {
259
- const normalizedRunId = normalizeText(runId);
260
- return {
261
- run_state_path: path.join(getRunsDir(), `${normalizedRunId}.json`),
262
- checkpoint_path: path.join(getRunsDir(), `${normalizedRunId}.checkpoint.json`)
263
- };
264
- }
294
+ function getRunArtifacts(runId) {
295
+ const normalizedRunId = normalizeText(runId);
296
+ return {
297
+ run_state_path: path.join(getRunsDir(), `${normalizedRunId}.json`),
298
+ checkpoint_path: path.join(getRunsDir(), `${normalizedRunId}.checkpoint.json`)
299
+ };
300
+ }
301
+
302
+ function writeRawRunState(runId, payload) {
303
+ const artifacts = getRunArtifacts(runId);
304
+ fs.mkdirSync(path.dirname(artifacts.run_state_path), { recursive: true });
305
+ const tempPath = `${artifacts.run_state_path}.tmp`;
306
+ fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
307
+ fs.renameSync(tempPath, artifacts.run_state_path);
308
+ return payload;
309
+ }
310
+
311
+ function readRawRunState(runId) {
312
+ const artifacts = getRunArtifacts(runId);
313
+ try {
314
+ const parsed = JSON.parse(fs.readFileSync(artifacts.run_state_path, "utf8"));
315
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
321
+ function patchRawRunState(runId, patch) {
322
+ const current = readRawRunState(runId);
323
+ if (!current) return null;
324
+ const now = new Date().toISOString();
325
+ const next = {
326
+ ...current,
327
+ ...patch,
328
+ run_id: current.run_id || runId,
329
+ updated_at: now,
330
+ heartbeat_at: current.heartbeat_at || now,
331
+ control: {
332
+ ...(current.control || {}),
333
+ ...(patch.control || {})
334
+ },
335
+ resume: {
336
+ ...(current.resume || {}),
337
+ ...(patch.resume || {})
338
+ }
339
+ };
340
+ return writeRawRunState(runId, next);
341
+ }
342
+
343
+ function createDetachedRecommendRunId() {
344
+ const suffix = Math.random().toString(36).slice(2, 10);
345
+ return `mcp_recommend_${Date.now().toString(36)}_${suffix}`;
346
+ }
265
347
 
266
348
  function buildRunContext(workspaceRoot, args = {}) {
349
+ const clonedArgs = clonePlain(args, {});
267
350
  return {
268
351
  workspace_root: path.resolve(workspaceRoot),
352
+ args: clonedArgs,
269
353
  instruction: String(args?.instruction || ""),
270
354
  confirmation: args?.confirmation && typeof args.confirmation === "object" ? args.confirmation : {},
271
355
  overrides: args?.overrides && typeof args.overrides === "object" ? args.overrides : {},
@@ -273,25 +357,40 @@ function buildRunContext(workspaceRoot, args = {}) {
273
357
  };
274
358
  }
275
359
 
276
- function resolveRunContext(snapshot) {
277
- const workspaceRoot = normalizeText(snapshot?.context?.workspace_root || "");
278
- const instruction = typeof snapshot?.context?.instruction === "string"
279
- ? snapshot.context.instruction
280
- : "";
281
- if (!workspaceRoot || !instruction.trim()) return null;
282
- return {
283
- workspaceRoot,
284
- args: {
360
+ function resolveRunContext(snapshot) {
361
+ const workspaceRoot = normalizeText(snapshot?.context?.workspace_root || "");
362
+ const storedArgs = snapshot?.context?.args && typeof snapshot.context.args === "object" && !Array.isArray(snapshot.context.args)
363
+ ? clonePlain(snapshot.context.args, {})
364
+ : null;
365
+ const instruction = typeof storedArgs?.instruction === "string"
366
+ ? storedArgs.instruction
367
+ : typeof snapshot?.context?.instruction === "string"
368
+ ? snapshot.context.instruction
369
+ : "";
370
+ const confirmation = storedArgs?.confirmation && typeof storedArgs.confirmation === "object" && !Array.isArray(storedArgs.confirmation)
371
+ ? storedArgs.confirmation
372
+ : snapshot?.context?.confirmation && typeof snapshot.context.confirmation === "object"
373
+ ? snapshot.context.confirmation
374
+ : {};
375
+ const overrides = storedArgs?.overrides && typeof storedArgs.overrides === "object" && !Array.isArray(storedArgs.overrides)
376
+ ? storedArgs.overrides
377
+ : snapshot?.context?.overrides && typeof snapshot.context.overrides === "object"
378
+ ? snapshot.context.overrides
379
+ : {};
380
+ const followUp = storedArgs && Object.prototype.hasOwnProperty.call(storedArgs, "follow_up")
381
+ ? storedArgs.follow_up
382
+ : snapshot?.context?.follow_up && typeof snapshot.context.follow_up === "object"
383
+ ? snapshot.context.follow_up
384
+ : null;
385
+ if (!workspaceRoot || !instruction.trim()) return null;
386
+ return {
387
+ workspaceRoot,
388
+ args: {
389
+ ...(storedArgs || {}),
285
390
  instruction,
286
- confirmation: snapshot?.context?.confirmation && typeof snapshot.context.confirmation === "object"
287
- ? snapshot.context.confirmation
288
- : {},
289
- overrides: snapshot?.context?.overrides && typeof snapshot.context.overrides === "object"
290
- ? snapshot.context.overrides
291
- : {},
292
- follow_up: snapshot?.context?.follow_up && typeof snapshot.context.follow_up === "object"
293
- ? snapshot.context.follow_up
294
- : null
391
+ confirmation,
392
+ overrides,
393
+ follow_up: followUp
295
394
  }
296
395
  };
297
396
  }
@@ -1827,35 +1926,184 @@ async function runDetachedWorker({ runId, resumeRun = false, workerPid = process
1827
1926
  : "detached worker 已启动,准备执行。"
1828
1927
  });
1829
1928
 
1830
- await executeTrackedPipeline({
1831
- runId: normalizedRunId,
1832
- mode: RUN_MODE_ASYNC,
1929
+ const started = await startRecommendPipelineRunTool({
1833
1930
  workspaceRoot: executionContext.workspaceRoot,
1834
1931
  args: executionContext.args,
1835
- signal: new AbortController().signal,
1836
- resumeRun
1932
+ runId: normalizedRunId
1837
1933
  });
1934
+ if (started?.status !== "ACCEPTED") {
1935
+ const failedPayload = started?.error || {
1936
+ code: "RUN_WORKER_START_FAILED",
1937
+ message: started?.status || "detached recommend worker failed to start",
1938
+ retryable: true
1939
+ };
1940
+ safeUpdateRunState(normalizedRunId, {
1941
+ state: RUN_STATE_FAILED,
1942
+ stage: snapshot.stage || RUN_STAGE_PREFLIGHT,
1943
+ last_message: failedPayload.message,
1944
+ error: failedPayload,
1945
+ result: {
1946
+ status: "FAILED",
1947
+ error: failedPayload
1948
+ }
1949
+ });
1950
+ return { ok: false, error: failedPayload.message };
1951
+ }
1952
+
1953
+ while (true) {
1954
+ const payload = getRecommendPipelineRunTool({ args: { run_id: normalizedRunId } });
1955
+ const state = normalizeText(payload?.run?.state || payload?.run?.status || "");
1956
+ if (TERMINAL_RUN_STATES.has(state)) break;
1957
+ const persisted = readRawRunState(normalizedRunId);
1958
+ if (persisted?.control?.cancel_requested === true) {
1959
+ cancelRecommendPipelineRunTool({ args: { run_id: normalizedRunId } });
1960
+ } else if (persisted?.control?.pause_requested === true && state === RUN_STATE_RUNNING) {
1961
+ pauseRecommendPipelineRunTool({ args: { run_id: normalizedRunId } });
1962
+ } else if (persisted?.control?.pause_requested === false && state === RUN_STATE_PAUSED) {
1963
+ resumeRecommendPipelineRunTool({ args: { run_id: normalizedRunId } });
1964
+ }
1965
+ await sleepMs(1000);
1966
+ }
1838
1967
  return { ok: true };
1839
1968
  }
1840
-
1969
+
1841
1970
  async function handleStartRunTool({ workspaceRoot, args }) {
1842
- return startRecommendPipelineRunTool({ workspaceRoot, args });
1971
+ if (!shouldStartRecommendDetached()) {
1972
+ return startRecommendPipelineRunTool({ workspaceRoot, args });
1973
+ }
1974
+
1975
+ const prepared = prepareRecommendPipelineRunTool({ workspaceRoot, args });
1976
+ if (prepared.status !== "READY") return prepared;
1977
+
1978
+ cleanupExpiredRuns();
1979
+ const runId = createDetachedRecommendRunId();
1980
+ try {
1981
+ initializeRunStateOrThrow(runId, RUN_MODE_ASYNC, workspaceRoot, args, process.pid);
1982
+ } catch (error) {
1983
+ return {
1984
+ status: "FAILED",
1985
+ error: {
1986
+ code: "RUN_STATE_IO_ERROR",
1987
+ message: `无法写入运行状态目录:${error.message || "unknown"}`,
1988
+ retryable: false
1989
+ }
1990
+ };
1991
+ }
1992
+
1993
+ let worker;
1994
+ try {
1995
+ worker = launchDetachedRunWorker({ runId });
1996
+ safeUpdateRunState(runId, { pid: worker.pid || process.pid });
1997
+ } catch (error) {
1998
+ const failed = buildWorkerLaunchFailedPayload(error?.message || "无法启动 detached recommend worker。");
1999
+ safeUpdateRunState(runId, {
2000
+ state: RUN_STATE_FAILED,
2001
+ stage: RUN_STAGE_PREFLIGHT,
2002
+ last_message: failed.error.message,
2003
+ error: failed.error,
2004
+ result: failed
2005
+ });
2006
+ return failed;
2007
+ }
2008
+
2009
+ const run = readRunState(runId);
2010
+ return {
2011
+ status: "ACCEPTED",
2012
+ run_id: runId,
2013
+ state: "queued",
2014
+ run,
2015
+ poll_after_sec: getRecommendedPollAfterSec(args),
2016
+ message: getDefaultAcceptedMessage(args),
2017
+ post_action: prepared.post_action,
2018
+ target_count_semantics: prepared.target_count_semantics,
2019
+ review: prepared.review
2020
+ };
1843
2021
  }
1844
2022
 
1845
2023
  function handleGetRunTool(args) {
1846
2024
  return getRecommendPipelineRunTool({ args });
1847
2025
  }
1848
2026
 
2027
+ function patchDetachedRecommendControl(args, controlPatch, {
2028
+ status,
2029
+ message,
2030
+ lastMessage
2031
+ } = {}) {
2032
+ const runId = normalizeText(args?.run_id || args?.runId || "");
2033
+ if (!runId) return null;
2034
+ const current = readRawRunState(runId);
2035
+ const state = normalizeText(current?.state || current?.status || "");
2036
+ if (!current || TERMINAL_RUN_STATES.has(state)) return null;
2037
+ const patched = patchRawRunState(runId, {
2038
+ last_message: lastMessage || message || current.last_message || "",
2039
+ control: controlPatch
2040
+ });
2041
+ if (!patched) return null;
2042
+ return {
2043
+ status,
2044
+ run: patched,
2045
+ message,
2046
+ persistence: {
2047
+ source: "disk",
2048
+ active_control_available: false,
2049
+ detached_control_requested: true
2050
+ },
2051
+ runtime_evaluate_used: false,
2052
+ method_summary: {},
2053
+ method_log: [],
2054
+ chrome: null
2055
+ };
2056
+ }
2057
+
1849
2058
  function handleCancelRunTool(args) {
1850
- return cancelRecommendPipelineRunTool({ args });
2059
+ const result = cancelRecommendPipelineRunTool({ args });
2060
+ if (result?.status === "RUN_STATUS" && result?.persistence?.active_control_available === false) {
2061
+ return patchDetachedRecommendControl(args, {
2062
+ pause_requested: true,
2063
+ pause_requested_at: new Date().toISOString(),
2064
+ pause_requested_by: TOOL_CANCEL_RUN,
2065
+ cancel_requested: true
2066
+ }, {
2067
+ status: "CANCEL_REQUESTED",
2068
+ message: "已收到取消请求,将由 detached worker 在下一个安全边界停止。",
2069
+ lastMessage: "已收到取消请求,将由 detached worker 在下一个安全边界停止。"
2070
+ }) || result;
2071
+ }
2072
+ return result;
1851
2073
  }
1852
2074
 
1853
2075
  function handlePauseRunTool(args) {
1854
- return pauseRecommendPipelineRunTool({ args });
2076
+ const result = pauseRecommendPipelineRunTool({ args });
2077
+ if (result?.status === "RUN_STATUS" && result?.persistence?.active_control_available === false) {
2078
+ return patchDetachedRecommendControl(args, {
2079
+ pause_requested: true,
2080
+ pause_requested_at: new Date().toISOString(),
2081
+ pause_requested_by: TOOL_PAUSE_RUN,
2082
+ cancel_requested: false
2083
+ }, {
2084
+ status: "PAUSE_REQUESTED",
2085
+ message: "暂停请求已写入 detached run 控制文件。",
2086
+ lastMessage: "暂停请求已写入 detached run 控制文件。"
2087
+ }) || result;
2088
+ }
2089
+ return result;
1855
2090
  }
1856
2091
 
1857
2092
  function handleResumeRunTool(args) {
1858
- return resumeRecommendPipelineRunTool({ args });
2093
+ const result = resumeRecommendPipelineRunTool({ args });
2094
+ if (result?.status === "FAILED" && result?.error?.code === "RUN_NOT_ACTIVE") {
2095
+ return patchDetachedRecommendControl(args, {
2096
+ pause_requested: false,
2097
+ pause_requested_at: null,
2098
+ pause_requested_by: null,
2099
+ cancel_requested: false
2100
+ }, {
2101
+ status: "RESUME_REQUESTED",
2102
+ message: "恢复请求已写入 detached run 控制文件。",
2103
+ lastMessage: "恢复请求已写入 detached run 控制文件。"
2104
+ }) || result;
2105
+ }
2106
+ return result;
1859
2107
  }
1860
2108
 
1861
2109
  function handleGetFeaturedCalibrationStatusTool(workspaceRoot) {
@@ -140,6 +140,15 @@ function clonePlain(value, fallback = null) {
140
140
  }
141
141
  }
142
142
 
143
+ function plainRecord(value) {
144
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
145
+ }
146
+
147
+ function nonEmptyRecord(value) {
148
+ const record = plainRecord(value);
149
+ return Object.keys(record).length ? record : null;
150
+ }
151
+
143
152
  function normalizeRunId(runId) {
144
153
  const normalized = normalizeText(runId);
145
154
  if (!normalized || normalized.includes("/") || normalized.includes("\\")) return "";
@@ -191,11 +200,29 @@ function recommendSearchParamsForCsv(searchParams = {}) {
191
200
  };
192
201
  }
193
202
 
194
- function selectedRecommendJobForCsv(meta = {}) {
203
+ function getSnapshotRequestContext(snapshot = {}) {
204
+ const context = plainRecord(snapshot?.context);
205
+ const shared = plainRecord(context.shared_run_context);
206
+ return {
207
+ context,
208
+ confirmation: nonEmptyRecord(context.confirmation) || plainRecord(shared.confirmation),
209
+ overrides: nonEmptyRecord(context.overrides) || plainRecord(shared.overrides),
210
+ followUp: context.follow_up ?? shared.follow_up ?? null,
211
+ shared
212
+ };
213
+ }
214
+
215
+ function selectedRecommendJobForCsv(meta = {}, snapshot = {}) {
216
+ const { confirmation, overrides, shared } = getSnapshotRequestContext(snapshot);
195
217
  const value = normalizeText(
196
218
  meta.args?.confirmation?.job_value
197
219
  || meta.normalized?.job
198
220
  || meta.args?.overrides?.job
221
+ || confirmation.job_value
222
+ || overrides.job
223
+ || shared.confirmation?.job_value
224
+ || shared.overrides?.job
225
+ || shared.job_label
199
226
  || ""
200
227
  );
201
228
  return {
@@ -206,21 +233,28 @@ function selectedRecommendJobForCsv(meta = {}) {
206
233
  }
207
234
 
208
235
  function buildRecommendCsvInputRows(snapshot = {}, meta = {}) {
209
- const searchParams = recommendSearchParamsForCsv(meta.parsed?.searchParams || {});
210
- const screenParams = meta.parsed?.screenParams || {};
236
+ const { context, confirmation, overrides, followUp, shared } = getSnapshotRequestContext(snapshot);
237
+ const searchParams = recommendSearchParamsForCsv(meta.parsed?.searchParams || {
238
+ school_tag: overrides.school_tag ?? confirmation.school_tag_value,
239
+ degree: overrides.degree ?? confirmation.degree_value,
240
+ gender: overrides.gender ?? confirmation.gender_value,
241
+ recent_not_view: overrides.recent_not_view ?? confirmation.recent_not_view_value
242
+ });
243
+ const parsedScreenParams = meta.parsed?.screenParams || {};
244
+ const screenParams = {
245
+ criteria: parsedScreenParams.criteria || meta.normalized?.criteria || overrides.criteria || "",
246
+ target_count: parsedScreenParams.target_count || snapshot.progress?.target_count || meta.normalized?.targetCount || overrides.target_count || confirmation.target_count_value || shared.max_candidates || "",
247
+ post_action: parsedScreenParams.post_action || overrides.post_action || confirmation.post_action_value || shared.post_action || "none",
248
+ max_greet_count: parsedScreenParams.max_greet_count ?? overrides.max_greet_count ?? confirmation.max_greet_count_value ?? shared.max_greet_count ?? ""
249
+ };
211
250
  return buildLegacyScreenInputRows({
212
- instruction: meta.args?.instruction || "",
251
+ instruction: meta.args?.instruction || context.instruction || shared.instruction || "",
213
252
  selectedPage: "recommend",
214
- selectedJob: selectedRecommendJobForCsv(meta),
253
+ selectedJob: selectedRecommendJobForCsv(meta, snapshot),
215
254
  userSearchParams: cloneReportInput(searchParams, {}),
216
255
  effectiveSearchParams: cloneReportInput(searchParams, {}),
217
- screenParams: {
218
- criteria: screenParams.criteria || meta.normalized?.criteria || "",
219
- target_count: screenParams.target_count || snapshot.progress?.target_count || meta.normalized?.targetCount || "",
220
- post_action: screenParams.post_action || "none",
221
- max_greet_count: screenParams.max_greet_count ?? ""
222
- },
223
- followUp: meta.args?.follow_up || meta.args?.overrides?.follow_up || null
256
+ screenParams,
257
+ followUp: meta.args?.follow_up || meta.args?.overrides?.follow_up || followUp || overrides.follow_up || null
224
258
  });
225
259
  }
226
260
 
@@ -237,6 +271,16 @@ function readRecommendRunState(runId) {
237
271
  return readJsonFile(artifacts.run_state_path);
238
272
  }
239
273
 
274
+ function isProcessAlive(pid) {
275
+ if (!Number.isInteger(pid) || pid <= 0) return false;
276
+ try {
277
+ process.kill(pid, 0);
278
+ return true;
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+
240
284
  function getRecommendRunMeta(runId) {
241
285
  return recommendRunMeta.get(runId) || {};
242
286
  }
@@ -475,12 +519,14 @@ function normalizeRunSnapshot(snapshot) {
475
519
  || snapshot.status === RUN_STATUS_PAUSED
476
520
  ) ? buildLegacyRecommendResult({ ...snapshot, progress }) : null;
477
521
  const recovery = buildConstrainedAgentRecovery(snapshot, meta, artifacts);
522
+ const snapshotContext = plainRecord(snapshot.context);
523
+ const metaArgs = plainRecord(meta.args);
478
524
  const oldContext = {
479
- workspace_root: meta.workspaceRoot || null,
480
- instruction: meta.args?.instruction || "",
481
- confirmation: clonePlain(meta.args?.confirmation || {}, {}),
482
- overrides: clonePlain(meta.args?.overrides || {}, {}),
483
- follow_up: clonePlain(meta.args?.follow_up || {}, {}),
525
+ workspace_root: meta.workspaceRoot || snapshotContext.workspace_root || null,
526
+ instruction: metaArgs.instruction || snapshotContext.instruction || "",
527
+ confirmation: clonePlain(metaArgs.confirmation ?? snapshotContext.confirmation ?? {}, {}),
528
+ overrides: clonePlain(metaArgs.overrides ?? snapshotContext.overrides ?? {}, {}),
529
+ follow_up: clonePlain(metaArgs.follow_up ?? snapshotContext.follow_up ?? null, null),
484
530
  target_count_semantics: TARGET_COUNT_SEMANTICS
485
531
  };
486
532
  return {
@@ -494,12 +540,12 @@ function normalizeRunSnapshot(snapshot) {
494
540
  updated_at: snapshot.updatedAt,
495
541
  completed_at: toIsoOrNull(snapshot.completedAt),
496
542
  heartbeat_at: snapshot.updatedAt,
497
- pid: process.pid || null,
543
+ pid: Number.isInteger(snapshot.pid) && snapshot.pid > 0 ? snapshot.pid : process.pid || null,
498
544
  last_message: snapshot.error?.message || snapshot.phase || null,
499
545
  context: {
500
- ...(snapshot.context || {}),
546
+ ...snapshotContext,
501
547
  ...oldContext,
502
- shared_run_context: snapshot.context || {}
548
+ shared_run_context: snapshotContext
503
549
  },
504
550
  control: {
505
551
  pause_requested: snapshot.status === RUN_STATUS_PAUSED,
@@ -521,6 +567,41 @@ function normalizeRunSnapshot(snapshot) {
521
567
  };
522
568
  }
523
569
 
570
+ function mergePersistedControlRequest(normalized, existing) {
571
+ const control = {
572
+ ...(normalized?.control || {})
573
+ };
574
+ if (!normalized || TERMINAL_STATUSES.has(normalized.state)) return control;
575
+ const existingControl = plainRecord(existing?.control);
576
+ if (existingControl.cancel_requested === true) {
577
+ return {
578
+ ...control,
579
+ pause_requested: true,
580
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
581
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "cancel_recommend_pipeline_run",
582
+ cancel_requested: true
583
+ };
584
+ }
585
+ if (existingControl.pause_requested === true && normalized.state !== RUN_STATUS_PAUSED) {
586
+ return {
587
+ ...control,
588
+ pause_requested: true,
589
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
590
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "pause_recommend_pipeline_run"
591
+ };
592
+ }
593
+ if (existingControl.pause_requested === false && normalized.state === RUN_STATUS_PAUSED) {
594
+ return {
595
+ ...control,
596
+ pause_requested: false,
597
+ pause_requested_at: null,
598
+ pause_requested_by: null,
599
+ cancel_requested: false
600
+ };
601
+ }
602
+ return control;
603
+ }
604
+
524
605
  function persistRecommendRunSnapshot(snapshot, {
525
606
  persistActiveCheckpoint = false
526
607
  } = {}) {
@@ -528,6 +609,8 @@ function persistRecommendRunSnapshot(snapshot, {
528
609
  if (!normalized?.run_id) return normalized;
529
610
  const artifacts = getRecommendRunArtifacts(normalized.run_id);
530
611
  if (!artifacts) return normalized;
612
+ const existing = readJsonFile(artifacts.run_state_path);
613
+ normalized.control = mergePersistedControlRequest(normalized, existing);
531
614
  if (persistActiveCheckpoint) {
532
615
  persistRecommendCheckpointSnapshot(normalized);
533
616
  }
@@ -557,6 +640,38 @@ function persistRecommendRunSnapshot(snapshot, {
557
640
  return normalized;
558
641
  }
559
642
 
643
+ function reconcilePersistedRecommendRunIfNeeded(persisted) {
644
+ if (!persisted || typeof persisted !== "object") return persisted;
645
+ const persistedState = normalizeText(persisted.state || persisted.status);
646
+ if (TERMINAL_STATUSES.has(persistedState)) return persisted;
647
+ if (isProcessAlive(persisted.pid)) return persisted;
648
+
649
+ const runId = normalizeRunId(persisted.run_id || persisted.runId);
650
+ const artifacts = getRecommendRunArtifacts(runId);
651
+ const checkpoint = artifacts?.checkpoint_path ? readJsonFile(artifacts.checkpoint_path) : null;
652
+ const now = new Date().toISOString();
653
+ const error = {
654
+ code: "RUN_PROCESS_EXITED",
655
+ message: `检测到推荐任务进程已退出(pid=${persisted.pid || "unknown"}),已自动标记为失败。`,
656
+ retryable: true
657
+ };
658
+ return persistRecommendRunSnapshot({
659
+ runId,
660
+ name: persisted.name || runId,
661
+ status: RUN_STATUS_FAILED,
662
+ phase: persisted.stage || persisted.phase || "recommend:orphaned",
663
+ progress: persisted.progress || {},
664
+ context: persisted.context || {},
665
+ checkpoint: checkpoint || persisted.checkpoint || {},
666
+ startedAt: persisted.started_at || persisted.startedAt || now,
667
+ updatedAt: now,
668
+ completedAt: now,
669
+ pid: Number.isInteger(persisted.pid) && persisted.pid > 0 ? persisted.pid : null,
670
+ error,
671
+ summary: persisted.summary || null
672
+ });
673
+ }
674
+
560
675
  function persistRecommendLifecycleSnapshot(snapshot, event = {}) {
561
676
  return persistRecommendRunSnapshot(snapshot, {
562
677
  persistActiveCheckpoint: event?.type === "checkpoint"
@@ -1154,6 +1269,47 @@ function getRunOptions(args, parsed, normalized, session, configResolution = nul
1154
1269
  };
1155
1270
  }
1156
1271
 
1272
+ function prepareRecommendPipelineStart(args = {}, { workspaceRoot = "" } = {}) {
1273
+ const parsed = parseRecommendPipelineRequest(args);
1274
+ const gate = evaluateRecommendPipelineGate(parsed, args);
1275
+ if (gate) return { response: gate };
1276
+ const configResolution = resolveBossScreeningConfig(workspaceRoot);
1277
+ const normalized = normalizeRecommendStartInput(args, parsed, configResolution);
1278
+ const debugTestOptions = collectRecommendDebugTestOptions(args, normalized);
1279
+ if (debugTestOptions.length && !isDebugTestMode(args)) {
1280
+ return {
1281
+ response: {
1282
+ status: "FAILED",
1283
+ error: {
1284
+ code: "DEBUG_TEST_MODE_REQUIRED",
1285
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1286
+ retryable: false
1287
+ },
1288
+ debug_test_options: debugTestOptions
1289
+ }
1290
+ };
1291
+ }
1292
+ if (normalized.screeningMode === "llm" && !configResolution.ok) {
1293
+ return {
1294
+ response: {
1295
+ status: "FAILED",
1296
+ error: {
1297
+ code: "SCREEN_CONFIG_ERROR",
1298
+ message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1299
+ retryable: true
1300
+ },
1301
+ config_path: configResolution.config_path || null,
1302
+ candidate_paths: configResolution.candidate_paths || []
1303
+ }
1304
+ };
1305
+ }
1306
+ return {
1307
+ parsed,
1308
+ configResolution,
1309
+ normalized
1310
+ };
1311
+ }
1312
+
1157
1313
  async function closeRecommendRunSession(runId) {
1158
1314
  const meta = recommendRunMeta.get(runId);
1159
1315
  if (!meta || meta.closed) return;
@@ -1199,34 +1355,19 @@ function trackRecommendRun(runId) {
1199
1355
  });
1200
1356
  }
1201
1357
 
1202
- async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = "" } = {}) {
1203
- const parsed = parseRecommendPipelineRequest(args);
1204
- const gate = evaluateRecommendPipelineGate(parsed, args);
1205
- if (gate) return gate;
1206
- const configResolution = resolveBossScreeningConfig(workspaceRoot);
1207
- const normalized = normalizeRecommendStartInput(args, parsed, configResolution);
1208
- const debugTestOptions = collectRecommendDebugTestOptions(args, normalized);
1209
- if (debugTestOptions.length && !isDebugTestMode(args)) {
1358
+ async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = "", runId = "" } = {}) {
1359
+ const prepared = prepareRecommendPipelineStart(args, { workspaceRoot });
1360
+ if (prepared.response) return prepared.response;
1361
+ const { parsed, configResolution, normalized } = prepared;
1362
+ const fixedRunId = normalizeRunId(runId);
1363
+ if (runId && !fixedRunId) {
1210
1364
  return {
1211
1365
  status: "FAILED",
1212
1366
  error: {
1213
- code: "DEBUG_TEST_MODE_REQUIRED",
1214
- message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1367
+ code: "INVALID_RUN_ID",
1368
+ message: "run_id is invalid",
1215
1369
  retryable: false
1216
- },
1217
- debug_test_options: debugTestOptions
1218
- };
1219
- }
1220
- if (normalized.screeningMode === "llm" && !configResolution.ok) {
1221
- return {
1222
- status: "FAILED",
1223
- error: {
1224
- code: "SCREEN_CONFIG_ERROR",
1225
- message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1226
- retryable: true
1227
- },
1228
- config_path: configResolution.config_path || null,
1229
- candidate_paths: configResolution.candidate_paths || []
1370
+ }
1230
1371
  };
1231
1372
  }
1232
1373
 
@@ -1260,7 +1401,11 @@ async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = ""
1260
1401
 
1261
1402
  let started;
1262
1403
  try {
1263
- started = recommendRunService.startRecommendRun(getRunOptions(args, parsed, normalized, session, configResolution));
1404
+ started = recommendRunService.startRecommendRun({
1405
+ ...getRunOptions(args, parsed, normalized, session, configResolution),
1406
+ runId: fixedRunId || undefined,
1407
+ pid: process.pid
1408
+ });
1264
1409
  } catch (error) {
1265
1410
  await session.close?.();
1266
1411
  return {
@@ -1311,8 +1456,24 @@ async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = ""
1311
1456
  };
1312
1457
  }
1313
1458
 
1314
- export async function startRecommendPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
1315
- const started = await startRecommendPipelineRunInternal(args, { workspaceRoot });
1459
+ export function prepareRecommendPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
1460
+ const prepared = prepareRecommendPipelineStart(args, { workspaceRoot });
1461
+ if (prepared.response) return prepared.response;
1462
+ const { parsed, normalized } = prepared;
1463
+ return {
1464
+ status: "READY",
1465
+ review: parsed.review,
1466
+ post_action: {
1467
+ requested: normalized.postAction,
1468
+ execute_post_action: args.dry_run_post_action === true ? false : args.execute_post_action !== false,
1469
+ max_greet_count: normalized.maxGreetCount
1470
+ },
1471
+ target_count_semantics: TARGET_COUNT_SEMANTICS
1472
+ };
1473
+ }
1474
+
1475
+ export async function startRecommendPipelineRunTool({ workspaceRoot = "", args = {}, runId = "" } = {}) {
1476
+ const started = await startRecommendPipelineRunInternal(args, { workspaceRoot, runId });
1316
1477
  if (started.status !== "ACCEPTED") return started;
1317
1478
  return attachMethodEvidence(started, started.run_id);
1318
1479
  }
@@ -1339,12 +1500,14 @@ export function getRecommendPipelineRunTool({ args = {} } = {}) {
1339
1500
  } catch {
1340
1501
  const persisted = readRecommendRunState(runId);
1341
1502
  if (persisted) {
1503
+ const reconciled = reconcilePersistedRecommendRunIfNeeded(persisted);
1342
1504
  return {
1343
1505
  status: "RUN_STATUS",
1344
- run: persisted,
1506
+ run: reconciled,
1345
1507
  persistence: {
1346
1508
  source: "disk",
1347
- active_control_available: false
1509
+ active_control_available: false,
1510
+ stale_process_reconciled: reconciled?.state !== persisted.state
1348
1511
  },
1349
1512
  runtime_evaluate_used: false,
1350
1513
  method_summary: {},