u-foo 2.5.5 → 2.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ucode.js +9 -0
- package/package.json +1 -1
- package/src/agents/launch/notifier.js +6 -0
- package/src/agents/launch/ptyRunner.js +2 -2
- package/src/agents/launch/ptyWrapper.js +2 -2
- package/src/agents/prompts/native/index.js +2 -2
- package/src/agents/prompts/native/toolDescriptions/bash.js +1 -1
- package/src/agents/prompts/native/toolDescriptions/edit.js +1 -0
- package/src/agents/prompts/native/toolDescriptions/read.js +3 -2
- package/src/code/agent.js +77 -1086
- package/src/code/busConsumer.js +504 -0
- package/src/code/dispatch.js +1 -6
- package/src/code/launcher/ucode.js +8 -254
- package/src/code/launcher/ucodeBootstrap.js +18 -1
- package/src/code/launcher/ucodeBuild.js +0 -3
- package/src/code/launcher/ucodeDoctor.js +26 -8
- package/src/code/launcher/ucodeRuntimeConfig.js +12 -3
- package/src/code/nativeRunner.js +93 -113
- package/src/code/repl.js +610 -0
- package/src/code/sessionStore.js +5 -1
- package/src/code/skills/injection.js +17 -1
- package/src/code/taskDecomposer.js +47 -31
- package/src/code/tools/bash.js +19 -2
- package/src/code/tools/common.js +34 -0
- package/src/code/tools/edit.js +11 -3
- package/src/code/tools/read.js +20 -2
- package/src/coordination/bus/inject.js +52 -7
- package/src/coordination/bus/subscriber.js +33 -6
- package/src/runtime/daemon/deliveryScheduler.js +102 -2
- package/src/runtime/daemon/index.js +8 -1
- package/src/runtime/daemon/ops.js +23 -0
|
@@ -12,8 +12,9 @@ function decomposeBugFixTask(task) {
|
|
|
12
12
|
const steps = [];
|
|
13
13
|
const taskContext = String(task || "");
|
|
14
14
|
|
|
15
|
-
// Analyze task to determine if it's a bug fix
|
|
16
|
-
|
|
15
|
+
// Analyze task to determine if it's a bug fix. Word boundaries keep
|
|
16
|
+
// substrings like "fixture"/"prefix"/"debug" from false-matching.
|
|
17
|
+
const isBugFix = /\b(?:fix(?:es|ed|ing)?|bugs?|issues?|problems?|errors?|broken)\b|doesn't work|not work/i.test(taskContext);
|
|
17
18
|
|
|
18
19
|
if (isBugFix) {
|
|
19
20
|
steps.push({
|
|
@@ -67,6 +68,17 @@ function clipStepOutput(value = "", maxChars = 2000) {
|
|
|
67
68
|
return `${text.slice(0, maxChars)}\n...[truncated]`;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
// Progress callbacks are user-supplied; a throwing callback must not abort
|
|
72
|
+
// the task (mirrors nativeRunner's emitToolEvent/emitPhase policy).
|
|
73
|
+
function reportProgress(callback, event = {}) {
|
|
74
|
+
if (typeof callback !== "function") return;
|
|
75
|
+
try {
|
|
76
|
+
callback(event);
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore callback failures
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
function buildStepPrompt(step, previousResults = []) {
|
|
71
83
|
const basePrompt = String(step && step.prompt ? step.prompt : "");
|
|
72
84
|
const prior = Array.isArray(previousResults) ? previousResults : [];
|
|
@@ -111,7 +123,6 @@ function shouldEarlyExitStep(step, stepResult = {}) {
|
|
|
111
123
|
*/
|
|
112
124
|
async function runDecomposedTask({
|
|
113
125
|
task,
|
|
114
|
-
state,
|
|
115
126
|
onProgress,
|
|
116
127
|
onToolEvent,
|
|
117
128
|
signal,
|
|
@@ -143,15 +154,13 @@ async function runDecomposedTask({
|
|
|
143
154
|
}
|
|
144
155
|
|
|
145
156
|
// Report progress
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
});
|
|
154
|
-
}
|
|
157
|
+
reportProgress(onProgress, {
|
|
158
|
+
type: "step_start",
|
|
159
|
+
step: step.id,
|
|
160
|
+
name: step.name,
|
|
161
|
+
current: steps.indexOf(step) + 1,
|
|
162
|
+
total: steps.length,
|
|
163
|
+
});
|
|
155
164
|
|
|
156
165
|
try {
|
|
157
166
|
// Run the step with its own timeout
|
|
@@ -176,14 +185,12 @@ async function runDecomposedTask({
|
|
|
176
185
|
});
|
|
177
186
|
|
|
178
187
|
// Report step completion
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
});
|
|
186
|
-
}
|
|
188
|
+
reportProgress(onProgress, {
|
|
189
|
+
type: "step_complete",
|
|
190
|
+
step: step.id,
|
|
191
|
+
name: step.name,
|
|
192
|
+
success: stepResult.ok,
|
|
193
|
+
});
|
|
187
194
|
|
|
188
195
|
// Early exit if solution found
|
|
189
196
|
if (shouldEarlyExitStep(step, stepResult)) {
|
|
@@ -202,14 +209,12 @@ async function runDecomposedTask({
|
|
|
202
209
|
|
|
203
210
|
} catch (err) {
|
|
204
211
|
// Report step error
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
});
|
|
212
|
-
}
|
|
212
|
+
reportProgress(onProgress, {
|
|
213
|
+
type: "step_error",
|
|
214
|
+
step: step.id,
|
|
215
|
+
name: step.name,
|
|
216
|
+
error: err.message,
|
|
217
|
+
});
|
|
213
218
|
|
|
214
219
|
return {
|
|
215
220
|
ok: false,
|
|
@@ -280,11 +285,22 @@ function compileSummary(results) {
|
|
|
280
285
|
return summaryParts.join("\n\n");
|
|
281
286
|
}
|
|
282
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Quote a value for safe inclusion in a shell command (single-quote style).
|
|
290
|
+
* Kept local to avoid a circular dependency with agent.js.
|
|
291
|
+
*/
|
|
292
|
+
function shellQuote(value = "") {
|
|
293
|
+
const text = String(value == null ? "" : value);
|
|
294
|
+
return `'${text.replace(/'/g, `'\"'\"'`)}'`;
|
|
295
|
+
}
|
|
296
|
+
|
|
283
297
|
/**
|
|
284
298
|
* Create a progress reporter that sends updates via bus
|
|
285
299
|
*/
|
|
286
300
|
function createBusProgressReporter(shell, publisher) {
|
|
287
|
-
|
|
301
|
+
// Start at 0 so the first event reports immediately instead of being
|
|
302
|
+
// swallowed by the throttle window.
|
|
303
|
+
let lastReportTime = 0;
|
|
288
304
|
const MIN_REPORT_INTERVAL = 5000; // Report at most every 5 seconds
|
|
289
305
|
|
|
290
306
|
return (progress) => {
|
|
@@ -297,10 +313,10 @@ function createBusProgressReporter(shell, publisher) {
|
|
|
297
313
|
|
|
298
314
|
if (progress.type === "step_start") {
|
|
299
315
|
const message = `⏳ ${progress.name} (${progress.current}/${progress.total})`;
|
|
300
|
-
shell(`ufoo bus send ${publisher} ${JSON.stringify(message)}`);
|
|
316
|
+
shell(`ufoo bus send ${shellQuote(publisher)} ${shellQuote(JSON.stringify(message))}`);
|
|
301
317
|
} else if (progress.type === "step_complete" && progress.success) {
|
|
302
318
|
const message = `✅ ${progress.name} completed`;
|
|
303
|
-
shell(`ufoo bus send ${publisher} ${JSON.stringify(message)}`);
|
|
319
|
+
shell(`ufoo bus send ${shellQuote(publisher)} ${shellQuote(JSON.stringify(message))}`);
|
|
304
320
|
}
|
|
305
321
|
};
|
|
306
322
|
}
|
package/src/code/tools/bash.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { spawnSync } = require("child_process");
|
|
2
2
|
const { normalizeWorkspaceRoot } = require("./common");
|
|
3
3
|
|
|
4
|
+
const MAX_TIMEOUT_MS = 600000;
|
|
5
|
+
|
|
4
6
|
function runBashTool(input = {}, options = {}) {
|
|
5
7
|
try {
|
|
6
8
|
const command = String(input.command || "").trim();
|
|
@@ -11,7 +13,9 @@ function runBashTool(input = {}, options = {}) {
|
|
|
11
13
|
};
|
|
12
14
|
}
|
|
13
15
|
const workspaceRoot = normalizeWorkspaceRoot(options.workspaceRoot, options.cwd);
|
|
14
|
-
const timeoutMs = Number.isFinite(input.timeoutMs)
|
|
16
|
+
const timeoutMs = Number.isFinite(input.timeoutMs)
|
|
17
|
+
? Math.min(MAX_TIMEOUT_MS, Math.max(100, Math.floor(input.timeoutMs)))
|
|
18
|
+
: 60000;
|
|
15
19
|
const result = spawnSync(command, {
|
|
16
20
|
cwd: workspaceRoot,
|
|
17
21
|
shell: true,
|
|
@@ -25,13 +29,26 @@ function runBashTool(input = {}, options = {}) {
|
|
|
25
29
|
ok: false,
|
|
26
30
|
workspaceRoot,
|
|
27
31
|
code: typeof result.status === "number" ? result.status : -1,
|
|
32
|
+
signal: result.signal || "",
|
|
28
33
|
stdout: String(result.stdout || ""),
|
|
29
34
|
stderr: String(result.stderr || ""),
|
|
30
35
|
error: result.error.message || "bash failed",
|
|
31
36
|
};
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
if (typeof result.status !== "number") {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
workspaceRoot,
|
|
43
|
+
code: -1,
|
|
44
|
+
signal: result.signal || "",
|
|
45
|
+
stdout: String(result.stdout || ""),
|
|
46
|
+
stderr: String(result.stderr || ""),
|
|
47
|
+
error: `command killed by signal ${result.signal || "unknown"}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const code = result.status;
|
|
35
52
|
return {
|
|
36
53
|
ok: code === 0,
|
|
37
54
|
workspaceRoot,
|
package/src/code/tools/common.js
CHANGED
|
@@ -13,6 +13,28 @@ function isPathInside(root, target) {
|
|
|
13
13
|
return normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function realpathOrNull(value) {
|
|
17
|
+
try {
|
|
18
|
+
return fs.realpathSync(value);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Realpath the nearest existing ancestor of a path (the path itself when it
|
|
25
|
+
// exists). The workspace root always exists by the time tools run, so the
|
|
26
|
+
// walk terminates there at the latest.
|
|
27
|
+
function realpathNearestExisting(value) {
|
|
28
|
+
let current = value;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const real = realpathOrNull(current);
|
|
31
|
+
if (real) return real;
|
|
32
|
+
const parent = path.dirname(current);
|
|
33
|
+
if (parent === current) return null;
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
function resolveWorkspacePath(workspaceRoot = "", targetPath = "", cwd = process.cwd()) {
|
|
17
39
|
const root = normalizeWorkspaceRoot(workspaceRoot, cwd);
|
|
18
40
|
const requested = String(targetPath || "").trim();
|
|
@@ -23,6 +45,18 @@ function resolveWorkspacePath(workspaceRoot = "", targetPath = "", cwd = process
|
|
|
23
45
|
if (!isPathInside(root, resolved)) {
|
|
24
46
|
throw new Error("path escapes workspace root");
|
|
25
47
|
}
|
|
48
|
+
// Lexical checks alone let a symlink inside the workspace point outside
|
|
49
|
+
// (e.g. link -> /etc). Re-validate with real paths: realpath the root too
|
|
50
|
+
// so legit roots behind symlinks (macOS /tmp -> /private/tmp) still pass.
|
|
51
|
+
// For missing files, checking the nearest existing ancestor is enough —
|
|
52
|
+
// the not-yet-created tail cannot contain a symlink.
|
|
53
|
+
const realRoot = realpathOrNull(root);
|
|
54
|
+
if (realRoot) {
|
|
55
|
+
const realTarget = realpathNearestExisting(resolved);
|
|
56
|
+
if (!realTarget || !isPathInside(realRoot, realTarget)) {
|
|
57
|
+
throw new Error("path escapes workspace root");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
26
60
|
return {
|
|
27
61
|
workspaceRoot: root,
|
|
28
62
|
requested,
|
package/src/code/tools/edit.js
CHANGED
|
@@ -47,14 +47,22 @@ function runEditTool(input = {}, options = {}) {
|
|
|
47
47
|
? replaceAll(original, find, replace)
|
|
48
48
|
: replaceOnce(original, find, replace);
|
|
49
49
|
const changed = applied.count > 0;
|
|
50
|
-
if (changed) {
|
|
51
|
-
|
|
50
|
+
if (!changed) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
workspaceRoot,
|
|
54
|
+
path: resolved,
|
|
55
|
+
changed: false,
|
|
56
|
+
replacements: 0,
|
|
57
|
+
error: `find pattern not found in ${resolved}`,
|
|
58
|
+
};
|
|
52
59
|
}
|
|
60
|
+
fs.writeFileSync(resolved, applied.next, "utf8");
|
|
53
61
|
return {
|
|
54
62
|
ok: true,
|
|
55
63
|
workspaceRoot,
|
|
56
64
|
path: resolved,
|
|
57
|
-
changed,
|
|
65
|
+
changed: true,
|
|
58
66
|
replacements: applied.count,
|
|
59
67
|
};
|
|
60
68
|
} catch (err) {
|
package/src/code/tools/read.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const { resolveWorkspacePath } = require("./common");
|
|
3
3
|
|
|
4
|
+
const MAX_FULL_READ_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
function readFileBounded(resolved) {
|
|
7
|
+
const stat = fs.statSync(resolved);
|
|
8
|
+
if (stat.size <= MAX_FULL_READ_BYTES) {
|
|
9
|
+
return { raw: fs.readFileSync(resolved, "utf8"), partial: false };
|
|
10
|
+
}
|
|
11
|
+
const fd = fs.openSync(resolved, "r");
|
|
12
|
+
try {
|
|
13
|
+
const buffer = Buffer.alloc(MAX_FULL_READ_BYTES);
|
|
14
|
+
const bytesRead = fs.readSync(fd, buffer, 0, MAX_FULL_READ_BYTES, 0);
|
|
15
|
+
return { raw: buffer.slice(0, bytesRead).toString("utf8"), partial: true };
|
|
16
|
+
} finally {
|
|
17
|
+
fs.closeSync(fd);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
function runReadTool(input = {}, options = {}) {
|
|
5
22
|
try {
|
|
6
23
|
const filePath = String(input.path || input.file || "").trim();
|
|
@@ -9,13 +26,13 @@ function runReadTool(input = {}, options = {}) {
|
|
|
9
26
|
const endLine = Number.isFinite(input.endLine) ? Math.max(startLine, Math.floor(input.endLine)) : 0;
|
|
10
27
|
const maxBytes = Number.isFinite(input.maxBytes) ? Math.max(256, Math.floor(input.maxBytes)) : 200000;
|
|
11
28
|
|
|
12
|
-
const raw =
|
|
29
|
+
const { raw, partial } = readFileBounded(resolved);
|
|
13
30
|
const lines = raw.split(/\r?\n/);
|
|
14
31
|
const from = startLine - 1;
|
|
15
32
|
const to = endLine > 0 ? endLine : lines.length;
|
|
16
33
|
const selected = lines.slice(from, to);
|
|
17
34
|
let content = selected.join("\n");
|
|
18
|
-
let truncated =
|
|
35
|
+
let truncated = partial;
|
|
19
36
|
if (Buffer.byteLength(content, "utf8") > maxBytes) {
|
|
20
37
|
content = Buffer.from(content, "utf8").slice(0, maxBytes).toString("utf8");
|
|
21
38
|
truncated = true;
|
|
@@ -41,4 +58,5 @@ function runReadTool(input = {}, options = {}) {
|
|
|
41
58
|
|
|
42
59
|
module.exports = {
|
|
43
60
|
runReadTool,
|
|
61
|
+
MAX_FULL_READ_BYTES,
|
|
44
62
|
};
|
|
@@ -12,6 +12,22 @@ const logInject = (message) => {
|
|
|
12
12
|
}
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// osascript 遇到权限弹窗或 System Events 挂起时会永不退出;tmux 客户端
|
|
16
|
+
// 卡死时同样不退出。daemon 的 DeliveryScheduler 会 await 这些 Promise,
|
|
17
|
+
// 没有超时就会永久持有该 subscriber 的推送锁,只能重启 daemon 恢复。
|
|
18
|
+
const SPAWN_TIMEOUT_MS = 10000;
|
|
19
|
+
|
|
20
|
+
function killAfterTimeout(proc, cmd, onTimeout) {
|
|
21
|
+
return setTimeout(() => {
|
|
22
|
+
try {
|
|
23
|
+
proc.kill("SIGKILL");
|
|
24
|
+
} catch {
|
|
25
|
+
// 进程已退出,无需处理
|
|
26
|
+
}
|
|
27
|
+
onTimeout(new Error(`${cmd} timeout after ${SPAWN_TIMEOUT_MS}ms`));
|
|
28
|
+
}, SPAWN_TIMEOUT_MS);
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
function escapeAppleScriptString(value) {
|
|
16
32
|
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
17
33
|
}
|
|
@@ -28,6 +44,8 @@ function runAppleScript(lines = []) {
|
|
|
28
44
|
let stderr = "";
|
|
29
45
|
let stdout = "";
|
|
30
46
|
|
|
47
|
+
const timeout = killAfterTimeout(proc, "osascript", reject);
|
|
48
|
+
|
|
31
49
|
proc.stdout.on("data", (data) => {
|
|
32
50
|
stdout += data.toString("utf8");
|
|
33
51
|
});
|
|
@@ -35,13 +53,17 @@ function runAppleScript(lines = []) {
|
|
|
35
53
|
stderr += data.toString("utf8");
|
|
36
54
|
});
|
|
37
55
|
proc.on("close", (code) => {
|
|
56
|
+
clearTimeout(timeout);
|
|
38
57
|
if (code === 0) {
|
|
39
58
|
resolve(stdout.trim());
|
|
40
59
|
} else {
|
|
41
60
|
reject(new Error(stderr.trim() || "AppleScript failed"));
|
|
42
61
|
}
|
|
43
62
|
});
|
|
44
|
-
proc.on("error",
|
|
63
|
+
proc.on("error", (err) => {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
reject(err);
|
|
66
|
+
});
|
|
45
67
|
});
|
|
46
68
|
}
|
|
47
69
|
|
|
@@ -109,15 +131,18 @@ class Injector {
|
|
|
109
131
|
* 检查 tmux pane 是否存在
|
|
110
132
|
*/
|
|
111
133
|
async checkTmuxPane(paneId) {
|
|
112
|
-
return new Promise((resolve) => {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
113
135
|
const proc = spawn("tmux", ["list-panes", "-a", "-F", "#{pane_id}"]);
|
|
114
136
|
let output = "";
|
|
115
137
|
|
|
138
|
+
const timeout = killAfterTimeout(proc, "tmux list-panes", reject);
|
|
139
|
+
|
|
116
140
|
proc.stdout.on("data", (data) => {
|
|
117
141
|
output += data.toString();
|
|
118
142
|
});
|
|
119
143
|
|
|
120
144
|
proc.on("close", (code) => {
|
|
145
|
+
clearTimeout(timeout);
|
|
121
146
|
if (code !== 0) {
|
|
122
147
|
resolve(false);
|
|
123
148
|
return;
|
|
@@ -126,7 +151,10 @@ class Injector {
|
|
|
126
151
|
resolve(panes.includes(paneId));
|
|
127
152
|
});
|
|
128
153
|
|
|
129
|
-
proc.on("error", () =>
|
|
154
|
+
proc.on("error", () => {
|
|
155
|
+
clearTimeout(timeout);
|
|
156
|
+
resolve(false);
|
|
157
|
+
});
|
|
130
158
|
});
|
|
131
159
|
}
|
|
132
160
|
|
|
@@ -134,15 +162,18 @@ class Injector {
|
|
|
134
162
|
* 根据 tty 查找 tmux pane
|
|
135
163
|
*/
|
|
136
164
|
async findTmuxPaneByTty(tty) {
|
|
137
|
-
return new Promise((resolve) => {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
138
166
|
const proc = spawn("tmux", ["list-panes", "-a", "-F", "#{pane_id} #{pane_tty}"]);
|
|
139
167
|
let output = "";
|
|
140
168
|
|
|
169
|
+
const timeout = killAfterTimeout(proc, "tmux list-panes", reject);
|
|
170
|
+
|
|
141
171
|
proc.stdout.on("data", (data) => {
|
|
142
172
|
output += data.toString();
|
|
143
173
|
});
|
|
144
174
|
|
|
145
175
|
proc.on("close", (code) => {
|
|
176
|
+
clearTimeout(timeout);
|
|
146
177
|
if (code !== 0) {
|
|
147
178
|
resolve(null);
|
|
148
179
|
return;
|
|
@@ -158,7 +189,10 @@ class Injector {
|
|
|
158
189
|
resolve(null);
|
|
159
190
|
});
|
|
160
191
|
|
|
161
|
-
proc.on("error", () =>
|
|
192
|
+
proc.on("error", () => {
|
|
193
|
+
clearTimeout(timeout);
|
|
194
|
+
resolve(null);
|
|
195
|
+
});
|
|
162
196
|
});
|
|
163
197
|
}
|
|
164
198
|
|
|
@@ -186,11 +220,14 @@ class Injector {
|
|
|
186
220
|
const textProc = spawn("tmux", ["send-keys", "-t", paneId, command]);
|
|
187
221
|
let stderr = "";
|
|
188
222
|
|
|
223
|
+
const textTimeout = killAfterTimeout(textProc, "tmux send-keys", reject);
|
|
224
|
+
|
|
189
225
|
textProc.stderr.on("data", (data) => {
|
|
190
226
|
stderr += data.toString();
|
|
191
227
|
});
|
|
192
228
|
|
|
193
229
|
textProc.on("close", (code) => {
|
|
230
|
+
clearTimeout(textTimeout);
|
|
194
231
|
if (code !== 0) {
|
|
195
232
|
reject(new Error(stderr || "tmux send-keys failed"));
|
|
196
233
|
return;
|
|
@@ -198,15 +235,23 @@ class Injector {
|
|
|
198
235
|
// Delay before sending Enter — gives the target app time to process input
|
|
199
236
|
setTimeout(() => {
|
|
200
237
|
const enterProc = spawn("tmux", ["send-keys", "-t", paneId, "Enter"]);
|
|
238
|
+
const enterTimeout = killAfterTimeout(enterProc, "tmux send-keys Enter", reject);
|
|
201
239
|
enterProc.on("close", (enterCode) => {
|
|
240
|
+
clearTimeout(enterTimeout);
|
|
202
241
|
if (enterCode === 0) resolve();
|
|
203
242
|
else reject(new Error("tmux send-keys Enter failed"));
|
|
204
243
|
});
|
|
205
|
-
enterProc.on("error",
|
|
244
|
+
enterProc.on("error", (err) => {
|
|
245
|
+
clearTimeout(enterTimeout);
|
|
246
|
+
reject(err);
|
|
247
|
+
});
|
|
206
248
|
}, 150);
|
|
207
249
|
});
|
|
208
250
|
|
|
209
|
-
textProc.on("error",
|
|
251
|
+
textProc.on("error", (err) => {
|
|
252
|
+
clearTimeout(textTimeout);
|
|
253
|
+
reject(err);
|
|
254
|
+
});
|
|
210
255
|
}
|
|
211
256
|
|
|
212
257
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
2
3
|
const { getTimestamp, isAgentPidAlive, isMetaActive, isValidTty, getTtyProcessInfo } = require("./utils");
|
|
3
4
|
const NicknameManager = require("./nickname");
|
|
4
5
|
const { spawnSync } = require("child_process");
|
|
@@ -113,8 +114,29 @@ class SubscriberManager {
|
|
|
113
114
|
appendAgentRegistryDiagnostic(this.agentsFile, event, payload);
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* 检查订阅者的 pending.jsonl 中是否仍有未投递消息
|
|
119
|
+
* 读取失败时保守返回 true,避免误删未投递消息
|
|
120
|
+
*/
|
|
121
|
+
hasUndeliveredPending(subscriber) {
|
|
122
|
+
try {
|
|
123
|
+
const pendingPath = this.queueManager.getPendingPath
|
|
124
|
+
? this.queueManager.getPendingPath(subscriber)
|
|
125
|
+
: (this.queueManager.getQueueDir
|
|
126
|
+
? path.join(this.queueManager.getQueueDir(subscriber), "pending.jsonl")
|
|
127
|
+
: "");
|
|
128
|
+
if (!pendingPath || !fs.existsSync(pendingPath)) return false;
|
|
129
|
+
return fs.readFileSync(pendingPath, "utf8").trim().length > 0;
|
|
130
|
+
} catch {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
cleanupSubscriberArtifacts(subscriber) {
|
|
117
136
|
if (!subscriber || !this.queueManager) return;
|
|
137
|
+
// pending.jsonl 里还有未投递消息时绝不能删队列目录和 offset 文件,
|
|
138
|
+
// 保留给订阅者复活或重新激活后继续投递;长期清理由其他机制负责。
|
|
139
|
+
if (this.hasUndeliveredPending(subscriber)) return;
|
|
118
140
|
try {
|
|
119
141
|
const queueDir = this.queueManager.getQueueDir
|
|
120
142
|
? this.queueManager.getQueueDir(subscriber)
|
|
@@ -168,16 +190,21 @@ class SubscriberManager {
|
|
|
168
190
|
nickname: meta?.nickname || "",
|
|
169
191
|
});
|
|
170
192
|
delete this.busData.agents[id];
|
|
193
|
+
// Migrate undelivered messages to the replacement subscriber before
|
|
194
|
+
// removing the stale queue, so a tty takeover does not drop them.
|
|
171
195
|
try {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
196
|
+
const dq = this.queueManager.getDeliveryQueue && this.queueManager.getDeliveryQueue(id);
|
|
197
|
+
const undelivered = dq && typeof dq.readPending === "function" ? dq.readPending() : [];
|
|
198
|
+
for (const event of undelivered) {
|
|
199
|
+
await this.queueManager.appendPending(currentSubscriber, event);
|
|
200
|
+
}
|
|
201
|
+
if (undelivered.length > 0 && typeof this.queueManager.clearPending === "function") {
|
|
202
|
+
await this.queueManager.clearPending(id);
|
|
175
203
|
}
|
|
176
|
-
const offsetPath = this.queueManager.getOffsetPath(id);
|
|
177
|
-
if (offsetPath) fs.rmSync(offsetPath, { force: true });
|
|
178
204
|
} catch {
|
|
179
|
-
//
|
|
205
|
+
// best-effort migration; the guard below keeps the queue if it failed
|
|
180
206
|
}
|
|
207
|
+
this.cleanupSubscriberArtifacts(id);
|
|
181
208
|
}
|
|
182
209
|
}
|
|
183
210
|
return inheritedNickname;
|
|
@@ -15,6 +15,20 @@ function isDeliverableActivityState(value = "") {
|
|
|
15
15
|
return state === "idle" || state === "ready";
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Warn once a subscriber has been continuously gate-deferred for this long.
|
|
19
|
+
const DEFAULT_DEFER_WARN_AFTER_MS = 60 * 1000;
|
|
20
|
+
// Minimum interval between repeated defer/lock warnings for the same subscriber.
|
|
21
|
+
const DEFAULT_WARN_INTERVAL_MS = 60 * 1000;
|
|
22
|
+
// After this long stuck in waiting_input/blocked, deliver anyway (better a
|
|
23
|
+
// message in a stuck terminal than a lost one). Env UFOO_DELIVERY_BLOCKED_GRACE_MS overrides.
|
|
24
|
+
const DEFAULT_BLOCKED_GRACE_MS = 15 * 60 * 1000;
|
|
25
|
+
// Warn when an inject lock is held longer than this (usually means a stuck inject).
|
|
26
|
+
const DEFAULT_LOCKED_WARN_AFTER_MS = 60 * 1000;
|
|
27
|
+
|
|
28
|
+
function positiveMs(value, fallback) {
|
|
29
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
function readAgentsFile(agentsFile) {
|
|
19
33
|
try {
|
|
20
34
|
if (!agentsFile || !fs.existsSync(agentsFile)) return { agents: {} };
|
|
@@ -45,15 +59,70 @@ class DeliveryScheduler {
|
|
|
45
59
|
? options.emitDelivery
|
|
46
60
|
: async () => {};
|
|
47
61
|
this.log = typeof options.log === "function" ? options.log : () => {};
|
|
62
|
+
this.now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
63
|
+
this.deferWarnAfterMs = positiveMs(options.deferWarnAfterMs, DEFAULT_DEFER_WARN_AFTER_MS);
|
|
64
|
+
this.warnIntervalMs = positiveMs(options.warnIntervalMs, DEFAULT_WARN_INTERVAL_MS);
|
|
65
|
+
this.blockedGraceMs = positiveMs(
|
|
66
|
+
options.blockedGraceMs,
|
|
67
|
+
positiveMs(Number(process.env.UFOO_DELIVERY_BLOCKED_GRACE_MS), DEFAULT_BLOCKED_GRACE_MS),
|
|
68
|
+
);
|
|
69
|
+
this.lockedWarnAfterMs = positiveMs(options.lockedWarnAfterMs, DEFAULT_LOCKED_WARN_AFTER_MS);
|
|
48
70
|
this.intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0
|
|
49
71
|
? options.intervalMs
|
|
50
72
|
: 1000;
|
|
51
73
|
this.adapterRouter = options.adapterRouter || createTerminalAdapterRouter();
|
|
52
|
-
this.locks = new
|
|
74
|
+
this.locks = new Map();
|
|
75
|
+
this.deferrals = new Map();
|
|
76
|
+
this.blockedStateSeen = new Map();
|
|
77
|
+
this.graceWarned = new Map();
|
|
53
78
|
this.timer = null;
|
|
54
79
|
this.running = false;
|
|
55
80
|
}
|
|
56
81
|
|
|
82
|
+
pendingCount(subscriber) {
|
|
83
|
+
try {
|
|
84
|
+
const queue = this.queueFactory(subscriber);
|
|
85
|
+
if (queue && typeof queue.readPending === "function") return queue.readPending().length;
|
|
86
|
+
} catch {
|
|
87
|
+
// logging must never break delivery
|
|
88
|
+
}
|
|
89
|
+
return "unknown";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
noteDeferral(subscriber, reason) {
|
|
93
|
+
const now = this.now();
|
|
94
|
+
const prev = this.deferrals.get(subscriber);
|
|
95
|
+
if (!prev || prev.reason !== reason) {
|
|
96
|
+
// Log once per reason change instead of on every 1s tick.
|
|
97
|
+
this.deferrals.set(subscriber, { reason, sinceMs: now, lastWarnAtMs: 0 });
|
|
98
|
+
this.log(`delivery deferred subscriber=${subscriber} reason=${reason} pending=${this.pendingCount(subscriber)}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (now - prev.sinceMs >= this.deferWarnAfterMs && now - prev.lastWarnAtMs >= this.warnIntervalMs) {
|
|
102
|
+
prev.lastWarnAtMs = now;
|
|
103
|
+
this.log(`WARN delivery still deferred subscriber=${subscriber} reason=${reason} pending=${this.pendingCount(subscriber)} deferred_ms=${now - prev.sinceMs}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
clearDeferral(subscriber) {
|
|
108
|
+
this.deferrals.delete(subscriber);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
noteGraceOverride(subscriber, activityState) {
|
|
112
|
+
if (this.graceWarned.get(subscriber) === activityState) return;
|
|
113
|
+
this.graceWarned.set(subscriber, activityState);
|
|
114
|
+
this.log(`WARN delivery grace override subscriber=${subscriber} activity_state=${activityState} pending=${this.pendingCount(subscriber)} grace_ms=${this.blockedGraceMs} - agent stuck, delivering anyway`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
noteLocked(subscriber, lock) {
|
|
118
|
+
const now = this.now();
|
|
119
|
+
const lockedMs = now - lock.sinceMs;
|
|
120
|
+
if (lockedMs >= this.lockedWarnAfterMs && now - lock.lastWarnAtMs >= this.warnIntervalMs) {
|
|
121
|
+
lock.lastWarnAtMs = now;
|
|
122
|
+
this.log(`WARN delivery lock held subscriber=${subscriber} locked_ms=${lockedMs} pending=${this.pendingCount(subscriber)} - previous inject may be stuck, daemon restart may be required`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
57
126
|
getAgentMeta(subscriber) {
|
|
58
127
|
const data = this.readAgents() || { agents: {} };
|
|
59
128
|
const agents = data.agents && typeof data.agents === "object" ? data.agents : {};
|
|
@@ -75,23 +144,52 @@ class DeliveryScheduler {
|
|
|
75
144
|
}
|
|
76
145
|
const activityState = asState(meta.activity_state);
|
|
77
146
|
if (!isDeliverableActivityState(activityState)) {
|
|
147
|
+
if (activityState === "waiting_input" || activityState === "blocked") {
|
|
148
|
+
const sinceMs = this.resolveBlockedStateSinceMs(subscriber, meta, activityState);
|
|
149
|
+
if (this.now() - sinceMs >= this.blockedGraceMs) {
|
|
150
|
+
return { ok: true, reason: "deliverable", graceOverride: activityState };
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
this.blockedStateSeen.delete(subscriber);
|
|
154
|
+
}
|
|
78
155
|
return { ok: false, reason: activityState || "unknown_activity_state" };
|
|
79
156
|
}
|
|
157
|
+
this.blockedStateSeen.delete(subscriber);
|
|
80
158
|
return { ok: true, reason: "deliverable" };
|
|
81
159
|
}
|
|
82
160
|
|
|
161
|
+
// Prefer meta.activity_since (written on state change); fall back to the
|
|
162
|
+
// first time this scheduler observed the state when the field is missing.
|
|
163
|
+
resolveBlockedStateSinceMs(subscriber, meta, activityState) {
|
|
164
|
+
const parsed = Date.parse(meta && meta.activity_since != null ? String(meta.activity_since) : "");
|
|
165
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
166
|
+
const seen = this.blockedStateSeen.get(subscriber);
|
|
167
|
+
if (seen && seen.state === activityState) return seen.sinceMs;
|
|
168
|
+
const now = this.now();
|
|
169
|
+
this.blockedStateSeen.set(subscriber, { state: activityState, sinceMs: now });
|
|
170
|
+
return now;
|
|
171
|
+
}
|
|
172
|
+
|
|
83
173
|
async deliverSubscriber(subscriber) {
|
|
84
174
|
if (!subscriber) return { ok: false, delivered: 0, reason: "missing_subscriber" };
|
|
85
175
|
if (this.locks.has(subscriber)) {
|
|
176
|
+
this.noteLocked(subscriber, this.locks.get(subscriber));
|
|
86
177
|
return { ok: true, delivered: 0, deferred: true, reason: "locked" };
|
|
87
178
|
}
|
|
88
179
|
|
|
89
|
-
this.locks.
|
|
180
|
+
this.locks.set(subscriber, { sinceMs: this.now(), lastWarnAtMs: 0 });
|
|
90
181
|
try {
|
|
91
182
|
const gate = this.shouldDeliver(subscriber);
|
|
92
183
|
if (!gate.ok) {
|
|
184
|
+
this.noteDeferral(subscriber, gate.reason);
|
|
93
185
|
return { ok: true, delivered: 0, deferred: true, reason: gate.reason };
|
|
94
186
|
}
|
|
187
|
+
if (gate.graceOverride) {
|
|
188
|
+
this.noteGraceOverride(subscriber, gate.graceOverride);
|
|
189
|
+
} else {
|
|
190
|
+
this.graceWarned.delete(subscriber);
|
|
191
|
+
}
|
|
192
|
+
this.clearDeferral(subscriber);
|
|
95
193
|
|
|
96
194
|
const queue = this.queueFactory(subscriber);
|
|
97
195
|
const claim = queue.claimNext();
|
|
@@ -114,9 +212,11 @@ class DeliveryScheduler {
|
|
|
114
212
|
if (delivery.gate === "idle") {
|
|
115
213
|
const secondGate = this.shouldDeliver(subscriber);
|
|
116
214
|
if (!secondGate.ok) {
|
|
215
|
+
this.noteDeferral(subscriber, secondGate.reason);
|
|
117
216
|
queue.restoreClaim(claim);
|
|
118
217
|
return { ok: true, delivered: 0, deferred: true, reason: secondGate.reason };
|
|
119
218
|
}
|
|
219
|
+
if (secondGate.graceOverride) this.noteGraceOverride(subscriber, secondGate.graceOverride);
|
|
120
220
|
}
|
|
121
221
|
|
|
122
222
|
const { agents } = this.getAgentMeta(subscriber);
|