@tea-agent/loop-agent 0.29.2 → 0.29.3
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/CHANGELOG.md +29 -27
- package/bin/agent-worker.js +0 -0
- package/dist/commands/client-recovery.js +2 -10
- package/dist/commands/init.js +1 -1
- package/dist/executors/pi-executor.js +5 -3
- package/dist/executors/pi-playwright-cli-tool.js +9 -14
- package/dist/executors/pi-sdk-executor.js +16 -0
- package/dist/shared/operator/capabilities.js +9 -9
- package/dist/shared/pi-retry-settings.js +23 -0
- package/dist/worker/console/chat/pi-runtime.js +167 -41
- package/dist/worker/console/chat/routes.js +21 -2
- package/dist/worker/console/chat/runtime-context.js +50 -8
- package/dist/worker/console/chat/tool-preview.js +90 -0
- package/dist/worker/console/chat/turn-process.js +178 -0
- package/dist/worker/console/chat/usage.js +144 -16
- package/dist/worker/console/night-aux-ticker.js +141 -0
- package/dist/worker/console/operation-runner.js +21 -4
- package/dist/worker/console/operator-user-error.js +12 -0
- package/dist/worker/console/recovery-cta.js +6 -6
- package/dist/worker/console/routes.js +61 -0
- package/dist/worker/console/server.js +7 -0
- package/dist/worker/console/static/assets/index-D9gnJn_l.js +29 -0
- package/dist/worker/console/static/assets/index-rajoXwkM.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/night-prepare-result.js +118 -0
- package/dist/worker/scheduler/clock-install/win32-schtasks.js +70 -15
- package/dist/workflows/dag/frontend-test-case-checklist.js +3 -3
- package/dist/workflows/dag/init-hybrid.js +21 -12
- package/docs/templates/frontend-test-case-checklist.md +1 -1
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.json +1 -1
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/init-managed-agents.md +6 -3
- package/package.json +1 -1
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +1 -1
- package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +0 -29
- package/dist/worker/console/static/assets/index-Yyn3ynVv.css +0 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn grouping for Operator Chat inline process UX (pi-web ProcessDetails mind).
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Restore chat bubbles + tool cards from durable session messages.
|
|
6
|
+
* Tool rows often appear before the final assistant `message_end` message;
|
|
7
|
+
* synthetic turn ids bridge that gap for later grouping.
|
|
8
|
+
*/
|
|
9
|
+
export function hydrateSessionThread(raw) {
|
|
10
|
+
const messages = [];
|
|
11
|
+
const toolCalls = [];
|
|
12
|
+
let turnId;
|
|
13
|
+
let pendingTools = [];
|
|
14
|
+
const flushPendingToAssistant = (assistantId) => {
|
|
15
|
+
for (const tool of pendingTools) {
|
|
16
|
+
if (!tool.assistantMessageId)
|
|
17
|
+
tool.assistantMessageId = assistantId;
|
|
18
|
+
if (!tool.turnId)
|
|
19
|
+
tool.turnId = turnId;
|
|
20
|
+
}
|
|
21
|
+
pendingTools = [];
|
|
22
|
+
};
|
|
23
|
+
for (const item of raw) {
|
|
24
|
+
const createdAt = Date.parse(item.at) || Date.now();
|
|
25
|
+
if (item.role === "user") {
|
|
26
|
+
turnId = `restored-${item.id}`;
|
|
27
|
+
pendingTools = [];
|
|
28
|
+
messages.push({
|
|
29
|
+
id: item.id,
|
|
30
|
+
role: "user",
|
|
31
|
+
text: item.text,
|
|
32
|
+
createdAt,
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (item.role === "assistant") {
|
|
37
|
+
if (!turnId)
|
|
38
|
+
turnId = `restored-${item.id}`;
|
|
39
|
+
messages.push({
|
|
40
|
+
id: item.id,
|
|
41
|
+
role: "assistant",
|
|
42
|
+
text: item.text,
|
|
43
|
+
createdAt,
|
|
44
|
+
});
|
|
45
|
+
flushPendingToAssistant(item.id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (item.role === "tool") {
|
|
49
|
+
if (!turnId)
|
|
50
|
+
turnId = `restored-tool-${item.id}`;
|
|
51
|
+
const entry = {
|
|
52
|
+
id: item.toolCallId || item.id,
|
|
53
|
+
toolName: item.toolName || "tool",
|
|
54
|
+
status: item.isError === true ? "error" : "ok",
|
|
55
|
+
resultText: item.text,
|
|
56
|
+
result: item.text,
|
|
57
|
+
createdAt,
|
|
58
|
+
finishedAt: createdAt,
|
|
59
|
+
turnId,
|
|
60
|
+
};
|
|
61
|
+
toolCalls.push(entry);
|
|
62
|
+
pendingTools.push(entry);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { messages, toolCalls };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build user → process → answer groups for the message timeline.
|
|
69
|
+
*/
|
|
70
|
+
export function buildTurnProcessGroups(input) {
|
|
71
|
+
const visible = input.messages.filter((m) => m.role === "user" || m.role === "assistant");
|
|
72
|
+
const groups = [];
|
|
73
|
+
let current = null;
|
|
74
|
+
const startGroup = (key) => {
|
|
75
|
+
const group = {
|
|
76
|
+
key,
|
|
77
|
+
assistants: [],
|
|
78
|
+
tools: [],
|
|
79
|
+
live: false,
|
|
80
|
+
};
|
|
81
|
+
groups.push(group);
|
|
82
|
+
return group;
|
|
83
|
+
};
|
|
84
|
+
for (const message of visible) {
|
|
85
|
+
if (message.role === "user") {
|
|
86
|
+
current = startGroup(`user-${message.id}`);
|
|
87
|
+
current.user = message;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!current)
|
|
91
|
+
current = startGroup(`assistant-${message.id}`);
|
|
92
|
+
current.assistants.push(message);
|
|
93
|
+
}
|
|
94
|
+
if (groups.length === 0 && input.toolCalls.length > 0) {
|
|
95
|
+
groups.push({
|
|
96
|
+
key: "orphan-tools",
|
|
97
|
+
assistants: [],
|
|
98
|
+
tools: [],
|
|
99
|
+
live: false,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const assigned = new Set();
|
|
103
|
+
const assistantToGroup = new Map();
|
|
104
|
+
for (const group of groups) {
|
|
105
|
+
for (const assistant of group.assistants) {
|
|
106
|
+
assistantToGroup.set(assistant.id, group);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const tool of input.toolCalls) {
|
|
110
|
+
if (tool.assistantMessageId) {
|
|
111
|
+
const group = assistantToGroup.get(tool.assistantMessageId);
|
|
112
|
+
if (group) {
|
|
113
|
+
group.tools.push(tool);
|
|
114
|
+
assigned.add(tool.id);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const tool of input.toolCalls) {
|
|
120
|
+
if (assigned.has(tool.id))
|
|
121
|
+
continue;
|
|
122
|
+
if (tool.turnId) {
|
|
123
|
+
const byTurn = groups.find((group) => group.tools.some((t) => t.turnId === tool.turnId) ||
|
|
124
|
+
group.key === `turn-${tool.turnId}` ||
|
|
125
|
+
(group.user && `restored-${group.user.id}` === tool.turnId));
|
|
126
|
+
if (byTurn) {
|
|
127
|
+
byTurn.tools.push(tool);
|
|
128
|
+
assigned.add(tool.id);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Match restored turn id `restored-<userOrAssistantId>`
|
|
132
|
+
const restored = groups.find((group) => tool.turnId === `restored-${group.user?.id}` ||
|
|
133
|
+
group.assistants.some((a) => tool.turnId === `restored-${a.id}`));
|
|
134
|
+
if (restored) {
|
|
135
|
+
restored.tools.push(tool);
|
|
136
|
+
assigned.add(tool.id);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const tool of input.toolCalls) {
|
|
142
|
+
if (assigned.has(tool.id))
|
|
143
|
+
continue;
|
|
144
|
+
const windowGroup = groups.find((group, index) => {
|
|
145
|
+
const start = group.user?.createdAt ?? group.assistants[0]?.createdAt ?? 0;
|
|
146
|
+
const next = groups[index + 1];
|
|
147
|
+
const end = next?.user?.createdAt ?? Number.POSITIVE_INFINITY;
|
|
148
|
+
return tool.createdAt >= start && tool.createdAt < end;
|
|
149
|
+
});
|
|
150
|
+
if (windowGroup) {
|
|
151
|
+
windowGroup.tools.push(tool);
|
|
152
|
+
assigned.add(tool.id);
|
|
153
|
+
}
|
|
154
|
+
else if (groups.length > 0) {
|
|
155
|
+
groups[groups.length - 1].tools.push(tool);
|
|
156
|
+
assigned.add(tool.id);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const group of groups) {
|
|
160
|
+
group.tools.sort((a, b) => a.createdAt - b.createdAt);
|
|
161
|
+
group.live =
|
|
162
|
+
input.streaming &&
|
|
163
|
+
input.activeAssistantId !== null &&
|
|
164
|
+
group.assistants.some((a) => a.id === input.activeAssistantId);
|
|
165
|
+
}
|
|
166
|
+
return groups;
|
|
167
|
+
}
|
|
168
|
+
export function processGroupSummary(tools) {
|
|
169
|
+
const count = tools.length;
|
|
170
|
+
const errorCount = tools.filter((t) => t.status === "error").length;
|
|
171
|
+
const runningCount = tools.filter((t) => t.status === "running").length;
|
|
172
|
+
const parts = [`执行过程 · ${count} 工具`];
|
|
173
|
+
if (errorCount > 0)
|
|
174
|
+
parts.push(`${errorCount} 出错`);
|
|
175
|
+
else if (runningCount > 0)
|
|
176
|
+
parts.push("运行中");
|
|
177
|
+
return { count, errorCount, runningCount, label: parts.join(" · ") };
|
|
178
|
+
}
|
|
@@ -2,36 +2,164 @@ function record(value) {
|
|
|
2
2
|
return value && typeof value === "object" ? value : undefined;
|
|
3
3
|
}
|
|
4
4
|
function numberAt(source, keys) {
|
|
5
|
-
for (const key of keys)
|
|
6
|
-
if (typeof source[key] === "number" && Number.isFinite(source[key]))
|
|
5
|
+
for (const key of keys) {
|
|
6
|
+
if (typeof source[key] === "number" && Number.isFinite(source[key])) {
|
|
7
7
|
return Math.max(0, source[key]);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
8
10
|
return undefined;
|
|
9
11
|
}
|
|
12
|
+
function costAt(source) {
|
|
13
|
+
const direct = numberAt(source, ["cost"]);
|
|
14
|
+
if (direct !== undefined)
|
|
15
|
+
return direct;
|
|
16
|
+
const nested = record(source.cost);
|
|
17
|
+
if (!nested)
|
|
18
|
+
return undefined;
|
|
19
|
+
return numberAt(nested, ["total", "amount"]);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Extract usage from Pi SDK / OpenAI-shaped events.
|
|
23
|
+
* Pi assistant usage uses `{ input, output, cacheRead, cacheWrite, totalTokens, cost }`.
|
|
24
|
+
*/
|
|
10
25
|
export function extractUsageSample(value) {
|
|
11
26
|
const event = record(value);
|
|
12
27
|
if (!event)
|
|
13
28
|
return undefined;
|
|
14
29
|
const message = record(event.message);
|
|
15
|
-
const source = record(event.usage) ??
|
|
30
|
+
const source = record(event.usage) ??
|
|
31
|
+
record(message?.usage) ??
|
|
32
|
+
record(event.tokenUsage);
|
|
16
33
|
if (!source)
|
|
17
34
|
return undefined;
|
|
18
|
-
const inputTokens = numberAt(source, [
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
const inputTokens = numberAt(source, [
|
|
36
|
+
"input",
|
|
37
|
+
"input_tokens",
|
|
38
|
+
"inputTokens",
|
|
39
|
+
"prompt_tokens",
|
|
40
|
+
"promptTokens",
|
|
41
|
+
]);
|
|
42
|
+
const outputTokens = numberAt(source, [
|
|
43
|
+
"output",
|
|
44
|
+
"output_tokens",
|
|
45
|
+
"outputTokens",
|
|
46
|
+
"completion_tokens",
|
|
47
|
+
"completionTokens",
|
|
48
|
+
]);
|
|
49
|
+
const cacheReadTokens = numberAt(source, [
|
|
50
|
+
"cacheRead",
|
|
51
|
+
"cache_read",
|
|
52
|
+
"cache_read_input_tokens",
|
|
53
|
+
"cacheReadTokens",
|
|
54
|
+
]);
|
|
55
|
+
const cacheWriteTokens = numberAt(source, [
|
|
56
|
+
"cacheWrite",
|
|
57
|
+
"cache_write",
|
|
58
|
+
"cache_creation_input_tokens",
|
|
59
|
+
"cacheWriteTokens",
|
|
60
|
+
]);
|
|
61
|
+
const partsSum = (inputTokens ?? 0) +
|
|
62
|
+
(outputTokens ?? 0) +
|
|
63
|
+
(cacheReadTokens ?? 0) +
|
|
64
|
+
(cacheWriteTokens ?? 0);
|
|
65
|
+
const hasParts = inputTokens !== undefined ||
|
|
66
|
+
outputTokens !== undefined ||
|
|
67
|
+
cacheReadTokens !== undefined ||
|
|
68
|
+
cacheWriteTokens !== undefined;
|
|
69
|
+
const totalTokens = numberAt(source, ["total_tokens", "totalTokens"]) ??
|
|
70
|
+
(hasParts ? partsSum : undefined);
|
|
71
|
+
const cost = costAt(source);
|
|
72
|
+
if (inputTokens === undefined &&
|
|
73
|
+
outputTokens === undefined &&
|
|
74
|
+
cacheReadTokens === undefined &&
|
|
75
|
+
cacheWriteTokens === undefined &&
|
|
76
|
+
totalTokens === undefined &&
|
|
77
|
+
cost === undefined) {
|
|
22
78
|
return undefined;
|
|
23
|
-
|
|
24
|
-
|
|
79
|
+
}
|
|
80
|
+
const responseKey = [
|
|
81
|
+
event.responseId,
|
|
82
|
+
message?.id,
|
|
83
|
+
source.responseId,
|
|
84
|
+
].find((item) => typeof item === "string");
|
|
85
|
+
return {
|
|
86
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
87
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
88
|
+
...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
|
|
89
|
+
...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}),
|
|
90
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
91
|
+
...(cost !== undefined ? { cost } : {}),
|
|
92
|
+
...(responseKey ? { responseKey } : {}),
|
|
93
|
+
};
|
|
25
94
|
}
|
|
26
95
|
export function aggregateUsage(samples) {
|
|
27
96
|
const keyed = new Map();
|
|
28
97
|
const anonymous = [];
|
|
29
|
-
for (const sample of samples)
|
|
30
|
-
|
|
98
|
+
for (const sample of samples) {
|
|
99
|
+
if (sample.responseKey)
|
|
100
|
+
keyed.set(sample.responseKey, sample);
|
|
101
|
+
else
|
|
102
|
+
anonymous.push(sample);
|
|
103
|
+
}
|
|
31
104
|
const selected = [...keyed.values(), ...anonymous];
|
|
32
|
-
return selected.reduce((sum, sample) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
105
|
+
return selected.reduce((sum, sample) => {
|
|
106
|
+
const input = sample.inputTokens ?? 0;
|
|
107
|
+
const output = sample.outputTokens ?? 0;
|
|
108
|
+
const cacheRead = sample.cacheReadTokens ?? 0;
|
|
109
|
+
const cacheWrite = sample.cacheWriteTokens ?? 0;
|
|
110
|
+
const total = sample.totalTokens ?? input + output + cacheRead + cacheWrite;
|
|
111
|
+
return {
|
|
112
|
+
inputTokens: sum.inputTokens + input,
|
|
113
|
+
outputTokens: sum.outputTokens + output,
|
|
114
|
+
cacheReadTokens: sum.cacheReadTokens + cacheRead,
|
|
115
|
+
cacheWriteTokens: sum.cacheWriteTokens + cacheWrite,
|
|
116
|
+
totalTokens: sum.totalTokens + total,
|
|
117
|
+
cost: sample.cost === undefined
|
|
118
|
+
? sum.cost
|
|
119
|
+
: (sum.cost ?? 0) + sample.cost,
|
|
120
|
+
estimated: Boolean(sum.estimated || sample.estimated),
|
|
121
|
+
};
|
|
122
|
+
}, {
|
|
123
|
+
inputTokens: 0,
|
|
124
|
+
outputTokens: 0,
|
|
125
|
+
cacheReadTokens: 0,
|
|
126
|
+
cacheWriteTokens: 0,
|
|
127
|
+
totalTokens: 0,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Compact token count aligned with pi-web SessionInfoBar / ChatInput. */
|
|
131
|
+
export function formatTokenCount(tokens) {
|
|
132
|
+
if (tokens >= 1_000_000)
|
|
133
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
134
|
+
if (tokens >= 1_000)
|
|
135
|
+
return `${(tokens / 1_000).toFixed(1)}k`;
|
|
136
|
+
return tokens.toLocaleString();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Status-line usage text aligned with pi-web SessionInfoBar chips:
|
|
140
|
+
* `12.3k in · 1.2k out · 8.0k cache R · $0.01`
|
|
141
|
+
*/
|
|
142
|
+
export function formatUsageStatusLine(usage) {
|
|
143
|
+
const parts = [];
|
|
144
|
+
const input = usage.inputTokens ?? 0;
|
|
145
|
+
const output = usage.outputTokens ?? 0;
|
|
146
|
+
const cacheRead = usage.cacheReadTokens ?? 0;
|
|
147
|
+
const cacheWrite = usage.cacheWriteTokens ?? 0;
|
|
148
|
+
const cost = usage.cost ?? 0;
|
|
149
|
+
if (input > 0)
|
|
150
|
+
parts.push(`${formatTokenCount(input)} in`);
|
|
151
|
+
if (output > 0)
|
|
152
|
+
parts.push(`${formatTokenCount(output)} out`);
|
|
153
|
+
if (cacheRead > 0)
|
|
154
|
+
parts.push(`${formatTokenCount(cacheRead)} cache R`);
|
|
155
|
+
if (cacheWrite > 0)
|
|
156
|
+
parts.push(`${formatTokenCount(cacheWrite)} cache W`);
|
|
157
|
+
if (cost > 0) {
|
|
158
|
+
parts.push(cost >= 0.01 ? `$${cost.toFixed(2)}` : "<$0.01");
|
|
159
|
+
}
|
|
160
|
+
if (parts.length === 0) {
|
|
161
|
+
const total = usage.totalTokens ?? 0;
|
|
162
|
+
return total > 0 ? `${formatTokenCount(total)} tokens` : "—";
|
|
163
|
+
}
|
|
164
|
+
return parts.join(" · ");
|
|
37
165
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { resolveSiblingAgentWorkerBin } from "./sibling-controller.js";
|
|
2
|
+
import { DEFAULT_CLOCK_INTERVAL_SEC } from "../scheduler/clock.js";
|
|
3
|
+
export const NIGHT_AUX_TICKER_DISCLAIMER = "仅在本 Console 进程开着时有效;关掉 Console 或睡觉后不会 tick。过夜无人值守请安装 OS clock:agent-worker scheduler clock install --repo .";
|
|
4
|
+
/**
|
|
5
|
+
* Optional Console-foreground scheduler ticker.
|
|
6
|
+
* Spawns sibling `agent-worker scheduler tick` so long dispatches do not block
|
|
7
|
+
* the Console HTTP event loop the way an in-process await would.
|
|
8
|
+
*/
|
|
9
|
+
export class NightAuxTicker {
|
|
10
|
+
deps;
|
|
11
|
+
enabled = false;
|
|
12
|
+
timer = null;
|
|
13
|
+
inFlight = false;
|
|
14
|
+
intervalSec;
|
|
15
|
+
now;
|
|
16
|
+
lastAttemptAt = null;
|
|
17
|
+
lastCompletedAt = null;
|
|
18
|
+
lastOutcome = null;
|
|
19
|
+
lastError = null;
|
|
20
|
+
lastSummary = null;
|
|
21
|
+
constructor(deps) {
|
|
22
|
+
this.deps = deps;
|
|
23
|
+
this.intervalSec = Math.max(15, Math.min(3600, deps.intervalSec ?? DEFAULT_CLOCK_INTERVAL_SEC));
|
|
24
|
+
this.now = deps.now ?? (() => new Date());
|
|
25
|
+
}
|
|
26
|
+
status() {
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
enabled: this.enabled,
|
|
30
|
+
running: this.inFlight,
|
|
31
|
+
intervalSec: this.intervalSec,
|
|
32
|
+
scope: "console-foreground",
|
|
33
|
+
disclaimer: NIGHT_AUX_TICKER_DISCLAIMER,
|
|
34
|
+
lastAttemptAt: this.lastAttemptAt,
|
|
35
|
+
lastCompletedAt: this.lastCompletedAt,
|
|
36
|
+
lastOutcome: this.lastOutcome,
|
|
37
|
+
lastError: this.lastError,
|
|
38
|
+
lastSummary: this.lastSummary,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
setEnabled(enabled) {
|
|
42
|
+
if (enabled === this.enabled)
|
|
43
|
+
return this.status();
|
|
44
|
+
this.enabled = enabled;
|
|
45
|
+
if (enabled) {
|
|
46
|
+
this.startTimer();
|
|
47
|
+
void this.fire("enable");
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
this.stopTimer();
|
|
51
|
+
}
|
|
52
|
+
return this.status();
|
|
53
|
+
}
|
|
54
|
+
dispose() {
|
|
55
|
+
this.enabled = false;
|
|
56
|
+
this.stopTimer();
|
|
57
|
+
}
|
|
58
|
+
startTimer() {
|
|
59
|
+
this.stopTimer();
|
|
60
|
+
this.timer = setInterval(() => {
|
|
61
|
+
void this.fire("interval");
|
|
62
|
+
}, this.intervalSec * 1000);
|
|
63
|
+
// Do not keep the process alive solely for the aux ticker.
|
|
64
|
+
this.timer.unref?.();
|
|
65
|
+
}
|
|
66
|
+
stopTimer() {
|
|
67
|
+
if (this.timer) {
|
|
68
|
+
clearInterval(this.timer);
|
|
69
|
+
this.timer = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async fire(_reason) {
|
|
73
|
+
if (!this.enabled)
|
|
74
|
+
return;
|
|
75
|
+
if (this.inFlight) {
|
|
76
|
+
this.lastOutcome = "skipped";
|
|
77
|
+
this.lastSummary = "previous tick still running";
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.inFlight = true;
|
|
81
|
+
this.lastAttemptAt = this.now().toISOString();
|
|
82
|
+
this.lastError = null;
|
|
83
|
+
try {
|
|
84
|
+
const result = await (this.deps.runTick
|
|
85
|
+
? this.deps.runTick()
|
|
86
|
+
: this.defaultRunTick());
|
|
87
|
+
this.lastCompletedAt = this.now().toISOString();
|
|
88
|
+
if (result.ok) {
|
|
89
|
+
this.lastOutcome = "ok";
|
|
90
|
+
this.lastSummary = result.summary;
|
|
91
|
+
this.lastError = null;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
this.lastOutcome = "error";
|
|
95
|
+
this.lastSummary = result.summary;
|
|
96
|
+
this.lastError = result.error ?? result.summary;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
this.lastCompletedAt = this.now().toISOString();
|
|
101
|
+
this.lastOutcome = "error";
|
|
102
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
103
|
+
this.lastSummary = null;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
this.inFlight = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async defaultRunTick() {
|
|
110
|
+
const bin = resolveSiblingAgentWorkerBin();
|
|
111
|
+
const result = await this.deps.client.runExternal(bin, [
|
|
112
|
+
"scheduler",
|
|
113
|
+
"tick",
|
|
114
|
+
"--repo",
|
|
115
|
+
this.deps.repoRoot,
|
|
116
|
+
"--clock-source",
|
|
117
|
+
"manual",
|
|
118
|
+
"--json",
|
|
119
|
+
], {
|
|
120
|
+
cwd: this.deps.repoRoot,
|
|
121
|
+
artifactName: "console-night-aux-tick",
|
|
122
|
+
expectJson: true,
|
|
123
|
+
});
|
|
124
|
+
if (!result.ok || result.exitCode !== 0) {
|
|
125
|
+
const message = (result.stderr || result.stdout || "").trim() ||
|
|
126
|
+
`scheduler tick exit ${result.exitCode}`;
|
|
127
|
+
return { ok: false, summary: message, error: message };
|
|
128
|
+
}
|
|
129
|
+
const json = result.json;
|
|
130
|
+
const claimed = Array.isArray(json?.claimed) ? json.claimed.length : 0;
|
|
131
|
+
const dispatched = Array.isArray(json?.dispatched)
|
|
132
|
+
? json.dispatched.length
|
|
133
|
+
: 0;
|
|
134
|
+
const waiting = Array.isArray(json?.waiting) ? json.waiting.length : 0;
|
|
135
|
+
const skipped = Array.isArray(json?.skipped) ? json.skipped.length : 0;
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
summary: `claimed=${claimed} dispatched=${dispatched} waiting=${waiting} skipped=${skipped}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -134,12 +134,10 @@ async function finalizeFromWorkerResult(operationId, result, deps) {
|
|
|
134
134
|
artifactRefs,
|
|
135
135
|
stdoutPreview: previewStdout,
|
|
136
136
|
stderrPreview: previewStderr,
|
|
137
|
-
result: envelope
|
|
138
|
-
exitCode: result.exitCode,
|
|
139
|
-
ok: result.ok,
|
|
137
|
+
result: materializeOperationResult(result, envelope, {
|
|
140
138
|
stdout: previewStdout,
|
|
141
139
|
stderr: previewStderr,
|
|
142
|
-
},
|
|
140
|
+
}),
|
|
143
141
|
errorCode: ok ? undefined : (envelope?.error?.code ?? "INTERNAL_ERROR"),
|
|
144
142
|
errorMessage: ok
|
|
145
143
|
? undefined
|
|
@@ -166,6 +164,25 @@ function coerceEnvelope(result) {
|
|
|
166
164
|
}
|
|
167
165
|
return undefined;
|
|
168
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Prefer OperatorCommandResultV1, then structured CLI JSON (e.g. admission
|
|
169
|
+
* reviewPacket), else a minimal exit preview. Losing CLI JSON breaks Console
|
|
170
|
+
* wizards that need scheduleId / gateToken after async operations.
|
|
171
|
+
*/
|
|
172
|
+
export function materializeOperationResult(result, envelope, previews) {
|
|
173
|
+
if (envelope)
|
|
174
|
+
return envelope;
|
|
175
|
+
const json = result.json;
|
|
176
|
+
if (json && typeof json === "object" && !Array.isArray(json)) {
|
|
177
|
+
return json;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
exitCode: result.exitCode,
|
|
181
|
+
ok: result.ok,
|
|
182
|
+
stdout: previews.stdout,
|
|
183
|
+
stderr: previews.stderr,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
169
186
|
/** Fire-and-forget scheduler for accepted operations. */
|
|
170
187
|
export function scheduleOperation(operationId, deps) {
|
|
171
188
|
void runOperation(operationId, deps).catch((error) => {
|
|
@@ -6,5 +6,17 @@ export function formatOperatorUserError(message) {
|
|
|
6
6
|
if (/controller identity changed|package fingerprint changed|entry binary sha256 changed/i.test(raw)) {
|
|
7
7
|
return "控制器版本已变更(Console 启动后代码被重新编译)。请重启 Console 后再试,一次会话中途不要升级/重建控制器。";
|
|
8
8
|
}
|
|
9
|
+
if (/Pi session file missing or not openable|cannot reopen chat session .* no persisted Pi session file/i.test(raw)) {
|
|
10
|
+
return "该对话的 Pi 会话文件已丢失,无法恢复上下文。请新建对话继续(历史记录无法重开)。";
|
|
11
|
+
}
|
|
12
|
+
if (/reopened Pi sessionId mismatch/i.test(raw)) {
|
|
13
|
+
return "重开对话失败:Pi 会话身份与记录不一致(常见于会话文件丢失后被误建)。请新建对话继续。";
|
|
14
|
+
}
|
|
15
|
+
if (/base HEAD .+ != frozen baseCommit|exact-base|not a descendant of frozen baseCommit/i.test(raw)) {
|
|
16
|
+
return "主分支已相对夜间冻结的 baseCommit 前进,无法安全 fast-forward harvest。请人工核对后使用 Discard(必要时勾选强制),或改走日间合并流程。";
|
|
17
|
+
}
|
|
18
|
+
if (/force-required|requires --force/i.test(raw)) {
|
|
19
|
+
return "该终态需要强制 Discard。请确认后勾选强制再试。";
|
|
20
|
+
}
|
|
9
21
|
return raw;
|
|
10
22
|
}
|
|
@@ -62,7 +62,7 @@ const CTA = {
|
|
|
62
62
|
requiresReason: true,
|
|
63
63
|
action: "dagRerun",
|
|
64
64
|
lane: "dag",
|
|
65
|
-
guide: "
|
|
65
|
+
guide: "终端失败默认首选:先 dagRerunPlan,plan 合格再 Human Gate 执行;provider 抖动/只读下游优先,勿无理由新开 task",
|
|
66
66
|
commandHint: "loop-agent dag rerun --run-id <run-id> --from-node <node-id> --plan",
|
|
67
67
|
},
|
|
68
68
|
standaloneTaskRerun: {
|
|
@@ -71,8 +71,8 @@ const CTA = {
|
|
|
71
71
|
requiresReason: true,
|
|
72
72
|
action: "standaloneTaskRerun",
|
|
73
73
|
lane: "dag",
|
|
74
|
-
guide: "
|
|
75
|
-
commandHint: "loop-agent task
|
|
74
|
+
guide: "整单重跑:仅当 from-node plan 不合格(writer/decision/fingerprint)或契约/源真变时;需确认原因",
|
|
75
|
+
commandHint: "loop-agent dag rerun-task --run-id <run-id> --reason <text> --json",
|
|
76
76
|
},
|
|
77
77
|
workerTaskRetry: {
|
|
78
78
|
id: "workerTaskRetry",
|
|
@@ -92,8 +92,8 @@ const MATRIX = {
|
|
|
92
92
|
"verification-failed": [
|
|
93
93
|
"report",
|
|
94
94
|
"doctor",
|
|
95
|
-
"standaloneTaskRerun",
|
|
96
95
|
"dagRerun",
|
|
96
|
+
"standaloneTaskRerun",
|
|
97
97
|
"resume",
|
|
98
98
|
"regenerate",
|
|
99
99
|
],
|
|
@@ -149,7 +149,7 @@ export function consoleRecoveryCtas(factClass) {
|
|
|
149
149
|
}
|
|
150
150
|
/**
|
|
151
151
|
* Suggested primary CTA for the current fact class.
|
|
152
|
-
* Prefer
|
|
152
|
+
* Prefer node subgraph rerun (cheaper) over whole-task rerun; plan gate still fail-closes unsafe closures.
|
|
153
153
|
*/
|
|
154
154
|
export function recommendedRecoveryCtaId(factClass) {
|
|
155
155
|
const allowed = new Set(consoleRecoveryCtas(factClass).map((c) => c.id));
|
|
@@ -163,7 +163,7 @@ export function recommendedRecoveryCtaId(factClass) {
|
|
|
163
163
|
switch (factClass) {
|
|
164
164
|
case "terminal-failed":
|
|
165
165
|
case "verification-failed":
|
|
166
|
-
return pick("
|
|
166
|
+
return pick("dagRerun", "standaloneTaskRerun", "doctor", "report");
|
|
167
167
|
case "pause-on-human":
|
|
168
168
|
return pick("doctor", "report");
|
|
169
169
|
case "needs-reconcile":
|
|
@@ -332,6 +332,59 @@ export async function serveConsoleStatic(req, res, ctx) {
|
|
|
332
332
|
res.writeHead(200, headers);
|
|
333
333
|
res.end(content);
|
|
334
334
|
}
|
|
335
|
+
async function handleNightAuxTickerGet(_req, res, ctx) {
|
|
336
|
+
const ticker = ctx.operator?.nightAuxTicker;
|
|
337
|
+
if (!ticker) {
|
|
338
|
+
sendJson(res, 503, {
|
|
339
|
+
ok: false,
|
|
340
|
+
error: {
|
|
341
|
+
code: "INTERNAL_ERROR",
|
|
342
|
+
message: "night aux ticker unavailable",
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
sendJson(res, 200, { ok: true, ticker: ticker.status() });
|
|
348
|
+
}
|
|
349
|
+
async function handleNightAuxTickerPost(req, res, ctx) {
|
|
350
|
+
const ticker = ctx.operator?.nightAuxTicker;
|
|
351
|
+
if (!ticker) {
|
|
352
|
+
sendJson(res, 503, {
|
|
353
|
+
ok: false,
|
|
354
|
+
error: {
|
|
355
|
+
code: "INTERNAL_ERROR",
|
|
356
|
+
message: "night aux ticker unavailable",
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
let body;
|
|
362
|
+
try {
|
|
363
|
+
body = (await readJsonBody(req));
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
sendJson(res, 400, {
|
|
367
|
+
ok: false,
|
|
368
|
+
error: {
|
|
369
|
+
code: "INVALID_INPUT",
|
|
370
|
+
message: error instanceof Error ? error.message : String(error),
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (typeof body.enabled !== "boolean") {
|
|
376
|
+
sendJson(res, 400, {
|
|
377
|
+
ok: false,
|
|
378
|
+
error: {
|
|
379
|
+
code: "INVALID_INPUT",
|
|
380
|
+
message: "enabled (boolean) is required",
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const status = ticker.setEnabled(body.enabled);
|
|
386
|
+
sendJson(res, 200, { ok: true, ticker: status });
|
|
387
|
+
}
|
|
335
388
|
export async function handleConsoleOperatorRequest(req, res, ctx) {
|
|
336
389
|
const method = (req.method ?? "GET").toUpperCase();
|
|
337
390
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -357,6 +410,10 @@ export async function handleConsoleOperatorRequest(req, res, ctx) {
|
|
|
357
410
|
await handleCreateOperation(req, res, ctx);
|
|
358
411
|
return true;
|
|
359
412
|
}
|
|
413
|
+
if (method === "GET" && pathname === "/api/operator/v1/night-aux-ticker") {
|
|
414
|
+
await handleNightAuxTickerGet(req, res, ctx);
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
360
417
|
const opMatch = pathname.match(/^\/api\/operator\/v1\/operations\/([^/]+)(?:\/(events))?$/);
|
|
361
418
|
if (opMatch) {
|
|
362
419
|
const operationId = decodeURIComponent(opMatch[1]);
|
|
@@ -383,6 +440,10 @@ export async function handleConsoleOperatorRequest(req, res, ctx) {
|
|
|
383
440
|
});
|
|
384
441
|
return true;
|
|
385
442
|
}
|
|
443
|
+
if (method === "POST" && pathname === "/api/operator/v1/night-aux-ticker") {
|
|
444
|
+
await handleNightAuxTickerPost(req, res, ctx);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
386
447
|
// Allow other operator POSTs only under known routes; unknown → 404
|
|
387
448
|
sendJson(res, 404, {
|
|
388
449
|
ok: false,
|