helloloop 0.8.4 → 0.8.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.
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { rememberEngineSelection } from "./engine_selection.mjs";
4
4
  import { getEngineDisplayName } from "./engine_metadata.mjs";
5
5
  import { ensureDir, nowIso, writeText } from "./common.mjs";
6
+ import { isHostLeaseAlive } from "./host_lease.mjs";
6
7
  import { saveBacklog } from "./config.mjs";
7
8
  import { reviewTaskCompletion } from "./completion_review.mjs";
8
9
  import { updateTask } from "./backlog.mjs";
@@ -13,6 +14,7 @@ import {
13
14
  buildBlockedResult,
14
15
  buildDoneResult,
15
16
  buildFailureResult,
17
+ buildStoppedResult,
16
18
  bumpFailureForNextStrategy,
17
19
  recordFailure,
18
20
  resolveExecutionSetup,
@@ -25,6 +27,18 @@ import {
25
27
  } from "./runner_status.mjs";
26
28
 
27
29
  async function handleEngineFailure(execution, state, attemptState, engineResult) {
30
+ if (engineResult.leaseExpired) {
31
+ return {
32
+ action: "return",
33
+ result: buildStoppedResult(
34
+ execution,
35
+ "host-lease-stopped",
36
+ engineResult.leaseReason || "检测到宿主窗口已关闭,当前任务已停止并回退为待处理。",
37
+ state.failureHistory.length,
38
+ state.engineResolution,
39
+ ),
40
+ };
41
+ }
28
42
  const previousFailure = buildFailureSummary("engine", {
29
43
  ...engineResult,
30
44
  displayName: getEngineDisplayName(state.engineResolution.engine),
@@ -49,6 +63,18 @@ async function handleEngineFailure(execution, state, attemptState, engineResult)
49
63
  }
50
64
 
51
65
  function handleVerifyFailure(execution, state, attemptState, verifyResult) {
66
+ if (verifyResult.failed?.leaseExpired) {
67
+ return {
68
+ action: "return",
69
+ result: buildStoppedResult(
70
+ execution,
71
+ "host-lease-stopped",
72
+ verifyResult.failed.leaseReason || "检测到宿主窗口已关闭,验证阶段已停止,当前任务已回退为待处理。",
73
+ state.failureHistory.length,
74
+ state.engineResolution,
75
+ ),
76
+ };
77
+ }
52
78
  const previousFailure = buildFailureSummary("verify", verifyResult);
53
79
  recordFailure(state.failureHistory, attemptState.strategyIndex, attemptState.attemptIndex, "verify", previousFailure);
54
80
 
@@ -68,6 +94,18 @@ function handleVerifyFailure(execution, state, attemptState, verifyResult) {
68
94
  }
69
95
 
70
96
  async function handleReviewFailure(execution, state, attemptState, reviewResult) {
97
+ if (reviewResult.raw?.leaseExpired) {
98
+ return {
99
+ action: "return",
100
+ result: buildStoppedResult(
101
+ execution,
102
+ "host-lease-stopped",
103
+ reviewResult.raw.leaseReason || "检测到宿主窗口已关闭,任务复核已停止,当前任务已回退为待处理。",
104
+ state.failureHistory.length,
105
+ state.engineResolution,
106
+ ),
107
+ };
108
+ }
71
109
  const previousFailure = reviewResult.summary;
72
110
  recordFailure(state.failureHistory, attemptState.strategyIndex, attemptState.attemptIndex, "task_review", previousFailure);
73
111
  return {
@@ -107,7 +145,12 @@ function handleIncompleteReview(execution, state, attemptState, reviewResult) {
107
145
  }
108
146
 
109
147
  async function handleVerifyAndReview(execution, state, attemptState, engineResult) {
110
- const verifyResult = await runVerifyCommands(execution.context, execution.verifyCommands, attemptState.attemptDir);
148
+ const verifyResult = await runVerifyCommands(
149
+ execution.context,
150
+ execution.verifyCommands,
151
+ attemptState.attemptDir,
152
+ { hostLease: execution.hostLease },
153
+ );
111
154
  if (!verifyResult.ok) {
112
155
  return handleVerifyFailure(execution, state, attemptState, verifyResult);
113
156
  }
@@ -123,6 +166,7 @@ async function handleVerifyAndReview(execution, state, attemptState, engineResul
123
166
  verifyResult,
124
167
  runDir: attemptState.attemptDir,
125
168
  policy: execution.policy,
169
+ hostLease: execution.hostLease,
126
170
  });
127
171
  if (!reviewResult.ok) {
128
172
  return handleReviewFailure(execution, state, attemptState, reviewResult);
@@ -163,6 +207,7 @@ async function runAttempt(execution, state, attemptState) {
163
207
  prompt,
164
208
  runDir: attemptState.attemptDir,
165
209
  policy: execution.policy,
210
+ hostLease: execution.hostLease,
166
211
  });
167
212
  if (!engineResult.ok) {
168
213
  return handleEngineFailure(execution, state, attemptState, engineResult);
@@ -175,6 +220,15 @@ export async function executeSingleTask(context, options = {}) {
175
220
  if (execution.idleResult) {
176
221
  return execution.idleResult;
177
222
  }
223
+ if (!isHostLeaseAlive(execution.hostLease)) {
224
+ return buildStoppedResult(
225
+ execution,
226
+ "host-lease-stopped",
227
+ "检测到宿主窗口已关闭,当前任务未继续执行,并已回退为待处理。",
228
+ 0,
229
+ execution.engineResolution,
230
+ );
231
+ }
178
232
  if (!execution.engineResolution.ok) {
179
233
  return {
180
234
  ok: false,
@@ -70,6 +70,7 @@ export async function resolveExecutionSetup(context, options = {}) {
70
70
  maxAttemptsPerStrategy,
71
71
  maxStrategies: policy.stopOnFailure ? 1 : configuredStrategies,
72
72
  engineResolution,
73
+ hostLease: options.hostLease || null,
73
74
  };
74
75
  }
75
76
 
@@ -84,6 +85,19 @@ function updateTaskAndBuildResult(execution, status, result) {
84
85
  return result;
85
86
  }
86
87
 
88
+ export function buildStoppedResult(execution, kind, summary, attempts, engineResolution) {
89
+ return updateTaskAndBuildResult(execution, "pending", {
90
+ ok: false,
91
+ stopped: true,
92
+ kind,
93
+ task: execution.task,
94
+ runDir: execution.runDir,
95
+ summary,
96
+ attempts,
97
+ engineResolution,
98
+ });
99
+ }
100
+
87
101
  export function buildFailureResult(execution, kind, summary, attempts, engineResolution) {
88
102
  return updateTaskAndBuildResult(execution, "failed", {
89
103
  ok: false,
@@ -1,8 +1,9 @@
1
1
  import path from "node:path";
2
2
 
3
- import { sanitizeId, tailText, timestampForFile } from "./common.mjs";
3
+ import { fileExists, readJson, sanitizeId, tailText, timestampForFile } from "./common.mjs";
4
4
  import { renderTaskSummary, selectNextTask, summarizeBacklog } from "./backlog.mjs";
5
5
  import { loadBacklog } from "./config.mjs";
6
+ import { renderHostLeaseLabel } from "./host_lease.mjs";
6
7
 
7
8
  export function makeRunDir(context, taskId) {
8
9
  return path.join(context.runsDir, `${timestampForFile()}-${sanitizeId(taskId)}`);
@@ -86,6 +87,9 @@ export function renderStatusText(context, options = {}) {
86
87
  const backlog = loadBacklog(context);
87
88
  const summary = summarizeBacklog(backlog);
88
89
  const nextTask = selectNextTask(backlog, options);
90
+ const supervisor = fileExists(context.supervisorStateFile)
91
+ ? readJson(context.supervisorStateFile)
92
+ : null;
89
93
 
90
94
  return [
91
95
  "HelloLoop 状态",
@@ -97,6 +101,13 @@ export function renderStatusText(context, options = {}) {
97
101
  `进行中:${summary.inProgress}`,
98
102
  `失败:${summary.failed}`,
99
103
  `阻塞:${summary.blocked}`,
104
+ ...(supervisor?.status
105
+ ? [
106
+ `后台会话:${supervisor.status}`,
107
+ `后台会话 ID:${supervisor.sessionId || "unknown"}`,
108
+ `后台租约:${renderHostLeaseLabel(supervisor.lease)}`,
109
+ ]
110
+ : []),
100
111
  "",
101
112
  nextTask ? "下一任务:" : "下一任务:无",
102
113
  nextTask ? renderTaskSummary(nextTask) : "",
@@ -0,0 +1,342 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { nowIso, writeText } from "./common.mjs";
5
+ import { getEngineDisplayName } from "./engine_metadata.mjs";
6
+ import {
7
+ buildClaudeArgs,
8
+ buildCodexArgs,
9
+ buildGeminiArgs,
10
+ runChild,
11
+ } from "./engine_process_support.mjs";
12
+ import { sendRuntimeStopNotification } from "./email_notification.mjs";
13
+ import { loadGlobalConfig } from "./global_config.mjs";
14
+ import {
15
+ buildEngineHealthProbePrompt,
16
+ classifyRuntimeRecoveryFailure,
17
+ } from "./runtime_recovery.mjs";
18
+ import { isHostLeaseAlive } from "./host_lease.mjs";
19
+
20
+ export async function sleepWithLease(ms, hostLease = null) {
21
+ const totalMs = Math.max(0, Number(ms || 0));
22
+ if (totalMs <= 0) {
23
+ return isHostLeaseAlive(hostLease);
24
+ }
25
+
26
+ const startedAt = Date.now();
27
+ while (Date.now() - startedAt < totalMs) {
28
+ if (!isHostLeaseAlive(hostLease)) {
29
+ return false;
30
+ }
31
+ const remaining = totalMs - (Date.now() - startedAt);
32
+ await new Promise((resolve) => {
33
+ setTimeout(resolve, Math.min(1000, Math.max(50, remaining)));
34
+ });
35
+ }
36
+ return isHostLeaseAlive(hostLease);
37
+ }
38
+
39
+ export function buildHostLeaseStoppedResult(reason) {
40
+ return {
41
+ ok: false,
42
+ code: 1,
43
+ stdout: "",
44
+ stderr: reason,
45
+ signal: "",
46
+ startedAt: nowIso(),
47
+ finishedAt: nowIso(),
48
+ idleTimeout: false,
49
+ watchdogTriggered: false,
50
+ watchdogReason: "",
51
+ leaseExpired: true,
52
+ leaseReason: reason,
53
+ };
54
+ }
55
+
56
+ export function createRuntimeStatusWriter(runtimeStatusFile, baseState) {
57
+ return function writeRuntimeStatus(status, extra = {}) {
58
+ writeJson(runtimeStatusFile, {
59
+ ...baseState,
60
+ ...extra,
61
+ status,
62
+ updatedAt: nowIso(),
63
+ });
64
+ };
65
+ }
66
+
67
+ function writeJson(filePath, value) {
68
+ writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
69
+ }
70
+
71
+ export function writeEngineRunArtifacts(runDir, prefix, result, finalMessage) {
72
+ writeText(path.join(runDir, `${prefix}-stdout.log`), result.stdout);
73
+ writeText(path.join(runDir, `${prefix}-stderr.log`), result.stderr);
74
+ writeText(path.join(runDir, `${prefix}-summary.txt`), [
75
+ `ok=${result.ok}`,
76
+ `code=${result.code}`,
77
+ `finished_at=${nowIso()}`,
78
+ "",
79
+ finalMessage,
80
+ ].join("\n"));
81
+ }
82
+
83
+ export function resolveEnginePolicy(policy = {}, engine) {
84
+ if (engine === "codex") {
85
+ return policy.codex || {};
86
+ }
87
+ if (engine === "claude") {
88
+ return policy.claude || {};
89
+ }
90
+ if (engine === "gemini") {
91
+ return policy.gemini || {};
92
+ }
93
+ return {};
94
+ }
95
+
96
+ function buildEngineArgs({
97
+ engine,
98
+ context,
99
+ resolvedPolicy,
100
+ executionMode,
101
+ outputSchemaFile,
102
+ ephemeral,
103
+ skipGitRepoCheck,
104
+ lastMessageFile,
105
+ probeMode = false,
106
+ }) {
107
+ if (engine === "codex") {
108
+ return buildCodexArgs({
109
+ context,
110
+ model: resolvedPolicy.model,
111
+ sandbox: resolvedPolicy.sandbox,
112
+ dangerouslyBypassSandbox: resolvedPolicy.dangerouslyBypassSandbox,
113
+ jsonOutput: probeMode ? false : (resolvedPolicy.jsonOutput !== false),
114
+ outputSchemaFile: probeMode ? "" : outputSchemaFile,
115
+ ephemeral,
116
+ skipGitRepoCheck,
117
+ lastMessageFile,
118
+ });
119
+ }
120
+
121
+ if (engine === "claude") {
122
+ return buildClaudeArgs({
123
+ model: resolvedPolicy.model,
124
+ outputSchemaFile: probeMode ? "" : outputSchemaFile,
125
+ executionMode: probeMode ? "execute" : executionMode,
126
+ policy: resolvedPolicy,
127
+ });
128
+ }
129
+
130
+ return buildGeminiArgs({
131
+ model: resolvedPolicy.model,
132
+ executionMode: probeMode ? "execute" : executionMode,
133
+ policy: resolvedPolicy,
134
+ });
135
+ }
136
+
137
+ function readEngineFinalMessage(engine, lastMessageFile, result) {
138
+ if (engine === "codex") {
139
+ return fs.existsSync(lastMessageFile)
140
+ ? fs.readFileSync(lastMessageFile, "utf8").trim()
141
+ : "";
142
+ }
143
+ return String(result.stdout || "").trim();
144
+ }
145
+
146
+ export async function runEngineAttempt({
147
+ engine,
148
+ invocation,
149
+ context,
150
+ prompt,
151
+ runDir,
152
+ attemptPrefix,
153
+ resolvedPolicy,
154
+ executionMode,
155
+ outputSchemaFile,
156
+ env,
157
+ recoveryPolicy,
158
+ writeRuntimeStatus,
159
+ recoveryCount,
160
+ recoveryHistory,
161
+ hostLease,
162
+ ephemeral = false,
163
+ skipGitRepoCheck = false,
164
+ probeMode = false,
165
+ }) {
166
+ const attemptPromptFile = path.join(runDir, `${attemptPrefix}-prompt.md`);
167
+ const attemptLastMessageFile = path.join(runDir, `${attemptPrefix}-last-message.txt`);
168
+
169
+ if (invocation.error) {
170
+ const result = {
171
+ ok: false,
172
+ code: 1,
173
+ stdout: "",
174
+ stderr: invocation.error,
175
+ signal: "",
176
+ startedAt: nowIso(),
177
+ finishedAt: nowIso(),
178
+ idleTimeout: false,
179
+ watchdogTriggered: false,
180
+ watchdogReason: "",
181
+ };
182
+ writeText(attemptPromptFile, prompt);
183
+ writeEngineRunArtifacts(runDir, attemptPrefix, result, "");
184
+ return {
185
+ result,
186
+ finalMessage: "",
187
+ attemptPrefix,
188
+ };
189
+ }
190
+
191
+ const finalArgs = [
192
+ ...invocation.argsPrefix,
193
+ ...buildEngineArgs({
194
+ engine,
195
+ context,
196
+ resolvedPolicy,
197
+ executionMode,
198
+ outputSchemaFile,
199
+ ephemeral,
200
+ skipGitRepoCheck,
201
+ lastMessageFile: attemptLastMessageFile,
202
+ probeMode,
203
+ }),
204
+ ];
205
+
206
+ writeRuntimeStatus(probeMode ? "probe_running" : (recoveryCount > 0 ? "recovering" : "running"), {
207
+ attemptPrefix,
208
+ recoveryCount,
209
+ recoveryHistory,
210
+ });
211
+
212
+ const result = await runChild(invocation.command, finalArgs, {
213
+ cwd: context.repoRoot,
214
+ stdin: prompt,
215
+ env,
216
+ shell: invocation.shell,
217
+ heartbeatIntervalMs: recoveryPolicy.heartbeatIntervalSeconds * 1000,
218
+ stallWarningMs: recoveryPolicy.stallWarningSeconds * 1000,
219
+ maxIdleMs: recoveryPolicy.maxIdleSeconds * 1000,
220
+ killGraceMs: recoveryPolicy.killGraceSeconds * 1000,
221
+ onHeartbeat(payload) {
222
+ writeRuntimeStatus(payload.status, {
223
+ attemptPrefix,
224
+ recoveryCount,
225
+ recoveryHistory,
226
+ heartbeat: payload,
227
+ });
228
+ },
229
+ shouldKeepRunning() {
230
+ return isHostLeaseAlive(hostLease);
231
+ },
232
+ leaseStopReason: "检测到宿主窗口已关闭,HelloLoop 已停止当前引擎进程。",
233
+ });
234
+ const finalMessage = readEngineFinalMessage(engine, attemptLastMessageFile, result);
235
+
236
+ writeText(attemptPromptFile, prompt);
237
+ writeEngineRunArtifacts(runDir, attemptPrefix, result, finalMessage);
238
+
239
+ return {
240
+ result,
241
+ finalMessage,
242
+ attemptPrefix,
243
+ };
244
+ }
245
+
246
+ export async function runEngineHealthProbe({
247
+ engine,
248
+ invocation,
249
+ context,
250
+ runDir,
251
+ resolvedPolicy,
252
+ recoveryPolicy,
253
+ writeRuntimeStatus,
254
+ recoveryCount,
255
+ recoveryHistory,
256
+ hostLease,
257
+ env,
258
+ probeIndex,
259
+ }) {
260
+ const probePrompt = buildEngineHealthProbePrompt(engine);
261
+ const attemptPrefix = `${engine}-probe-${String(probeIndex).padStart(2, "0")}`;
262
+ writeRuntimeStatus("probe_waiting", {
263
+ attemptPrefix,
264
+ recoveryCount,
265
+ recoveryHistory,
266
+ });
267
+ const attempt = await runEngineAttempt({
268
+ engine,
269
+ invocation,
270
+ context,
271
+ prompt: probePrompt,
272
+ runDir,
273
+ attemptPrefix,
274
+ resolvedPolicy,
275
+ executionMode: "execute",
276
+ outputSchemaFile: "",
277
+ env,
278
+ recoveryPolicy: {
279
+ ...recoveryPolicy,
280
+ maxIdleSeconds: recoveryPolicy.healthProbeTimeoutSeconds,
281
+ },
282
+ writeRuntimeStatus,
283
+ recoveryCount,
284
+ recoveryHistory,
285
+ hostLease,
286
+ ephemeral: true,
287
+ skipGitRepoCheck: true,
288
+ probeMode: true,
289
+ });
290
+
291
+ return {
292
+ ...attempt,
293
+ failure: classifyRuntimeRecoveryFailure({
294
+ result: {
295
+ ...attempt.result,
296
+ finalMessage: attempt.finalMessage,
297
+ },
298
+ }),
299
+ };
300
+ }
301
+
302
+ export async function maybeSendStopNotification({
303
+ context,
304
+ runDir,
305
+ engine,
306
+ executionMode,
307
+ failure,
308
+ result,
309
+ recoveryHistory,
310
+ }) {
311
+ try {
312
+ return await sendRuntimeStopNotification({
313
+ globalConfig: loadGlobalConfig(),
314
+ context,
315
+ engine: getEngineDisplayName(engine),
316
+ phase: executionMode === "analyze" ? "分析/复核" : "执行",
317
+ failure,
318
+ result,
319
+ recoveryHistory,
320
+ runDir,
321
+ });
322
+ } catch (error) {
323
+ return {
324
+ attempted: true,
325
+ delivered: false,
326
+ reason: String(error?.message || error || "邮件发送失败。"),
327
+ };
328
+ }
329
+ }
330
+
331
+ export function buildNotificationNote(notificationResult) {
332
+ if (!notificationResult) {
333
+ return "";
334
+ }
335
+ if (notificationResult.delivered) {
336
+ return `告警邮件已发送:${(notificationResult.recipients || []).join(", ")}`;
337
+ }
338
+ if (notificationResult.attempted) {
339
+ return `告警邮件发送失败:${notificationResult.reason || "未知原因"}`;
340
+ }
341
+ return `未发送告警邮件:${notificationResult.reason || "未启用"}`;
342
+ }