u-foo 2.5.6 → 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/prompts/native/index.js +1 -1
- package/src/agents/prompts/native/toolDescriptions/edit.js +1 -0
- package/src/code/agent.js +53 -1076
- package/src/code/busConsumer.js +504 -0
- package/src/code/dispatch.js +1 -6
- package/src/code/launcher/ucode.js +3 -251
- package/src/code/launcher/ucodeBootstrap.js +18 -1
- package/src/code/launcher/ucodeBuild.js +0 -3
- package/src/code/launcher/ucodeDoctor.js +24 -9
- package/src/code/launcher/ucodeRuntimeConfig.js +12 -3
- package/src/code/nativeRunner.js +62 -109
- 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 +36 -29
- package/src/code/tools/common.js +34 -0
- package/src/code/tools/edit.js +11 -3
- 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,
|
|
@@ -293,7 +298,9 @@ function shellQuote(value = "") {
|
|
|
293
298
|
* Create a progress reporter that sends updates via bus
|
|
294
299
|
*/
|
|
295
300
|
function createBusProgressReporter(shell, publisher) {
|
|
296
|
-
|
|
301
|
+
// Start at 0 so the first event reports immediately instead of being
|
|
302
|
+
// swallowed by the throttle window.
|
|
303
|
+
let lastReportTime = 0;
|
|
297
304
|
const MIN_REPORT_INTERVAL = 5000; // Report at most every 5 seconds
|
|
298
305
|
|
|
299
306
|
return (progress) => {
|
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) {
|
|
@@ -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);
|
|
@@ -1340,7 +1340,14 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
1340
1340
|
log(`report bus event failed request=${meta.requestId || ""} error=${err.message || String(err)}`);
|
|
1341
1341
|
}
|
|
1342
1342
|
});
|
|
1343
|
-
const deliveryScheduler = new DeliveryScheduler(projectRoot, {
|
|
1343
|
+
const deliveryScheduler = new DeliveryScheduler(projectRoot, {
|
|
1344
|
+
log,
|
|
1345
|
+
emitDelivery: async ({ subscriber, status, error } = {}) => {
|
|
1346
|
+
if (status === "error") {
|
|
1347
|
+
log(`delivery failed subscriber=${subscriber || "unknown"} error=${error || "unknown"}`);
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
});
|
|
1344
1351
|
deliveryScheduler.start();
|
|
1345
1352
|
|
|
1346
1353
|
handleIpcRequest = async (req, socket) => {
|
|
@@ -282,11 +282,28 @@ function buildShellEnvPrefix(extraEnv = {}) {
|
|
|
282
282
|
.join(" ");
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// osascript can hang forever on permission prompts or a stuck System Events;
|
|
286
|
+
// launch and close both await runAppleScript, so without a timeout the daemon
|
|
287
|
+
// would stay blocked until restart.
|
|
288
|
+
const APPLESCRIPT_TIMEOUT_MS = 10000;
|
|
289
|
+
|
|
290
|
+
function killAfterTimeout(proc, cmd, onTimeout) {
|
|
291
|
+
return setTimeout(() => {
|
|
292
|
+
try {
|
|
293
|
+
proc.kill("SIGKILL");
|
|
294
|
+
} catch {
|
|
295
|
+
// process already exited, nothing to kill
|
|
296
|
+
}
|
|
297
|
+
onTimeout(new Error(`${cmd} timeout after ${APPLESCRIPT_TIMEOUT_MS}ms`));
|
|
298
|
+
}, APPLESCRIPT_TIMEOUT_MS);
|
|
299
|
+
}
|
|
300
|
+
|
|
285
301
|
function runAppleScript(lines) {
|
|
286
302
|
return new Promise((resolve, reject) => {
|
|
287
303
|
const proc = spawn("osascript", lines.flatMap((l) => ["-e", l]));
|
|
288
304
|
let stderr = "";
|
|
289
305
|
let stdout = "";
|
|
306
|
+
const timeout = killAfterTimeout(proc, "osascript", reject);
|
|
290
307
|
proc.stderr.on("data", (d) => {
|
|
291
308
|
stderr += d.toString("utf8");
|
|
292
309
|
});
|
|
@@ -294,9 +311,14 @@ function runAppleScript(lines) {
|
|
|
294
311
|
stdout += d.toString("utf8");
|
|
295
312
|
});
|
|
296
313
|
proc.on("close", (code) => {
|
|
314
|
+
clearTimeout(timeout);
|
|
297
315
|
if (code === 0) resolve(stdout.trim());
|
|
298
316
|
else reject(new Error(stderr || "osascript failed"));
|
|
299
317
|
});
|
|
318
|
+
proc.on("error", (err) => {
|
|
319
|
+
clearTimeout(timeout);
|
|
320
|
+
reject(err);
|
|
321
|
+
});
|
|
300
322
|
});
|
|
301
323
|
}
|
|
302
324
|
|
|
@@ -1317,5 +1339,6 @@ module.exports = {
|
|
|
1317
1339
|
toTerminalBinary,
|
|
1318
1340
|
toTmuxBinary,
|
|
1319
1341
|
buildResumeArgs,
|
|
1342
|
+
runAppleScript,
|
|
1320
1343
|
},
|
|
1321
1344
|
};
|