@tea-agent/loop-agent 0.34.3 → 0.34.4
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/AGENTS.md +7 -2
- package/CHANGELOG.md +50 -22
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +85 -3
- package/dist/worker/console/chat/pi-runtime.js +230 -62
- package/dist/worker/console/chat/resource-preferences-store.js +152 -0
- package/dist/worker/console/chat/routes.js +401 -18
- package/dist/worker/console/chat/turn-execution-registry.js +82 -0
- package/dist/worker/console/dag-execution-receipt.js +14 -1
- package/dist/worker/console/server.js +4 -0
- package/dist/worker/console/static/assets/index-B6Qdbk8V.js +29 -0
- package/dist/worker/console/static/assets/index-Bt0NUxcQ.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/refs.js +24 -0
- package/dist/worker/console/static-src/operator-chat/resource-auto-invocation.js +91 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +690 -0
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +158 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +535 -86
- package/dist/workflows/dag/init-hybrid.js +2 -0
- package/docs/operations/local-development-environment.md +4 -2
- package/docs/templates/branch-merge-report.md +9 -0
- package/package.json +3 -2
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +0 -29
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
|
|
16
16
|
import { buildModelCallableToolSchemas } from "./tool-adapter.js";
|
|
17
17
|
import { cleanChatTitle } from "./session-store.js";
|
|
18
|
-
import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
|
|
18
|
+
import { hashChatTurnPayload, projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
|
|
19
19
|
import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
|
|
20
|
+
import { ResourcePreferencesError, setSkillAutoInvocationPreference, } from "./resource-preferences-store.js";
|
|
20
21
|
import { ComposerDraftStore } from "./composer-draft-store.js";
|
|
21
22
|
import { walkRepoFiles } from "./repo-walk.js";
|
|
22
23
|
import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
|
|
@@ -33,6 +34,23 @@ import { projectTaskContext } from "./context-panel.js";
|
|
|
33
34
|
import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "../human-gate-token.js";
|
|
34
35
|
import { sendJson } from "../routes.js";
|
|
35
36
|
import { openSseResponse, writeSseEvent } from "../operation-sse.js";
|
|
37
|
+
function turnSummary(turn) {
|
|
38
|
+
return {
|
|
39
|
+
turnId: turn.turnId,
|
|
40
|
+
clientRequestId: turn.clientRequestId ?? null,
|
|
41
|
+
state: turn.state,
|
|
42
|
+
ordinal: turn.ordinal,
|
|
43
|
+
createdAt: turn.createdAt,
|
|
44
|
+
startedAt: turn.startedAt ?? null,
|
|
45
|
+
finishedAt: turn.finishedAt ?? null,
|
|
46
|
+
error: turn.error ?? null,
|
|
47
|
+
abortRequestedAt: turn.abortRequestedAt ?? null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function isTerminalTurnState(state) {
|
|
51
|
+
return state === "settled" || state === "aborted" || state === "failed";
|
|
52
|
+
}
|
|
53
|
+
const DEFAULT_ABORT_WAIT_MS = 8_000;
|
|
36
54
|
/** Parse Chat's `<epoch>:<seq>` SSE cursor. Legacy `<sessionId>:<seq>` has
|
|
37
55
|
* the same wire shape; its prefix intentionally fails the current process
|
|
38
56
|
* epoch comparison and triggers safe snapshot recovery (M5.1). */
|
|
@@ -648,13 +666,25 @@ export async function handleGetChatSessionState(_req, res, deps, sessionId) {
|
|
|
648
666
|
}
|
|
649
667
|
const active = deps.events.getActiveTurn(sessionId);
|
|
650
668
|
const latest = deps.events.latestTurn(sessionId);
|
|
669
|
+
const runtimeFacts = typeof deps.runtime.getSessionBusyFacts === "function"
|
|
670
|
+
? deps.runtime.getSessionBusyFacts(sessionId)
|
|
671
|
+
: {
|
|
672
|
+
isStreaming: false,
|
|
673
|
+
isPromptRunning: false,
|
|
674
|
+
isCompacting: false,
|
|
675
|
+
isBashRunning: false,
|
|
676
|
+
};
|
|
651
677
|
sendJson(res, 200, {
|
|
652
678
|
ok: true,
|
|
653
679
|
state: {
|
|
680
|
+
// Legacy flat fields retained for older clients.
|
|
654
681
|
activeTurnId: active?.turnId ?? null,
|
|
655
682
|
activeTurnState: active?.state ?? null,
|
|
656
683
|
latestTurnId: latest?.turnId ?? null,
|
|
657
684
|
latestTurnState: latest?.state ?? null,
|
|
685
|
+
activeTurn: active ? turnSummary(active) : null,
|
|
686
|
+
latestTurn: latest ? turnSummary(latest) : null,
|
|
687
|
+
runtime: runtimeFacts,
|
|
658
688
|
},
|
|
659
689
|
});
|
|
660
690
|
}
|
|
@@ -862,12 +892,25 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
|
|
|
862
892
|
// prompt SSE route uses the same durable Turn resource as POST /turns.
|
|
863
893
|
const createdTurn = deps.events.createTurn(sessionId);
|
|
864
894
|
if (!createdTurn.ok) {
|
|
895
|
+
if (createdTurn.code === "REQUEST_ID_REUSE_CONFLICT") {
|
|
896
|
+
sendJson(res, 409, {
|
|
897
|
+
ok: false,
|
|
898
|
+
accepted: false,
|
|
899
|
+
error: {
|
|
900
|
+
code: createdTurn.code,
|
|
901
|
+
message: createdTurn.message,
|
|
902
|
+
},
|
|
903
|
+
});
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
865
906
|
sendJson(res, 409, {
|
|
866
907
|
ok: false,
|
|
908
|
+
accepted: false,
|
|
867
909
|
error: {
|
|
868
910
|
code: "TURN_ACTIVE",
|
|
869
|
-
message:
|
|
911
|
+
message: "上一轮仍在运行,已返回当前 activeTurn;请重新连接或稍后再试。",
|
|
870
912
|
},
|
|
913
|
+
activeTurn: turnSummary(createdTurn.activeTurn),
|
|
871
914
|
});
|
|
872
915
|
return;
|
|
873
916
|
}
|
|
@@ -1009,6 +1052,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1009
1052
|
if (!gate.ok) {
|
|
1010
1053
|
sendJson(res, gate.status, {
|
|
1011
1054
|
ok: false,
|
|
1055
|
+
accepted: false,
|
|
1012
1056
|
error: { code: gate.code, message: gate.message },
|
|
1013
1057
|
});
|
|
1014
1058
|
return;
|
|
@@ -1020,6 +1064,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1020
1064
|
catch (error) {
|
|
1021
1065
|
sendJson(res, 400, {
|
|
1022
1066
|
ok: false,
|
|
1067
|
+
accepted: false,
|
|
1023
1068
|
error: {
|
|
1024
1069
|
code: "INVALID_INPUT",
|
|
1025
1070
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -1028,6 +1073,9 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1028
1073
|
return;
|
|
1029
1074
|
}
|
|
1030
1075
|
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
1076
|
+
const clientRequestId = typeof body.clientRequestId === "string" && body.clientRequestId.trim()
|
|
1077
|
+
? body.clientRequestId.trim()
|
|
1078
|
+
: undefined;
|
|
1031
1079
|
let images;
|
|
1032
1080
|
try {
|
|
1033
1081
|
images = parseChatImages(body.images);
|
|
@@ -1035,6 +1083,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1035
1083
|
catch (error) {
|
|
1036
1084
|
sendJson(res, 400, {
|
|
1037
1085
|
ok: false,
|
|
1086
|
+
accepted: false,
|
|
1038
1087
|
error: {
|
|
1039
1088
|
code: "INVALID_ATTACHMENT",
|
|
1040
1089
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -1045,6 +1094,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1045
1094
|
if (!text) {
|
|
1046
1095
|
sendJson(res, 400, {
|
|
1047
1096
|
ok: false,
|
|
1097
|
+
accepted: false,
|
|
1048
1098
|
error: { code: "INVALID_INPUT", message: "text is required" },
|
|
1049
1099
|
});
|
|
1050
1100
|
return;
|
|
@@ -1054,6 +1104,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1054
1104
|
if (!record) {
|
|
1055
1105
|
sendJson(res, 404, {
|
|
1056
1106
|
ok: false,
|
|
1107
|
+
accepted: false,
|
|
1057
1108
|
error: {
|
|
1058
1109
|
code: "NOT_FOUND",
|
|
1059
1110
|
message: `chat session not found: ${sessionId}`,
|
|
@@ -1075,6 +1126,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1075
1126
|
});
|
|
1076
1127
|
sendJson(res, mapped.status, {
|
|
1077
1128
|
ok: false,
|
|
1129
|
+
accepted: false,
|
|
1078
1130
|
error: { code: mapped.code, message: mapped.message },
|
|
1079
1131
|
});
|
|
1080
1132
|
return;
|
|
@@ -1084,6 +1136,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1084
1136
|
if (!actionContext) {
|
|
1085
1137
|
sendJson(res, 503, {
|
|
1086
1138
|
ok: false,
|
|
1139
|
+
accepted: false,
|
|
1087
1140
|
error: {
|
|
1088
1141
|
code: "CHAT_NO_ACTION_CONTEXT",
|
|
1089
1142
|
message: "Chat runtime has no operator action context wired",
|
|
@@ -1091,30 +1144,78 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1091
1144
|
});
|
|
1092
1145
|
return;
|
|
1093
1146
|
}
|
|
1094
|
-
const
|
|
1147
|
+
const payloadHash = hashChatTurnPayload({ text, images });
|
|
1148
|
+
const created = deps.events.createTurn(sessionId, {
|
|
1149
|
+
clientRequestId,
|
|
1150
|
+
payloadHash,
|
|
1151
|
+
});
|
|
1095
1152
|
if (!created.ok) {
|
|
1153
|
+
if (created.code === "REQUEST_ID_REUSE_CONFLICT") {
|
|
1154
|
+
sendJson(res, 409, {
|
|
1155
|
+
ok: false,
|
|
1156
|
+
accepted: false,
|
|
1157
|
+
error: {
|
|
1158
|
+
code: created.code,
|
|
1159
|
+
message: created.message,
|
|
1160
|
+
},
|
|
1161
|
+
});
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1096
1164
|
sendJson(res, 409, {
|
|
1097
1165
|
ok: false,
|
|
1166
|
+
accepted: false,
|
|
1098
1167
|
error: {
|
|
1099
1168
|
code: "TURN_ACTIVE",
|
|
1100
|
-
message:
|
|
1169
|
+
message: "上一轮仍在运行,已返回当前 activeTurn;请重新连接或稍后再试。",
|
|
1101
1170
|
},
|
|
1171
|
+
activeTurn: turnSummary(created.activeTurn),
|
|
1102
1172
|
});
|
|
1103
1173
|
return;
|
|
1104
1174
|
}
|
|
1105
1175
|
const turn = created.turn;
|
|
1176
|
+
// Idempotent retry of an already-accepted request: return the same Turn and
|
|
1177
|
+
// never re-run the prompt (even if it already finished).
|
|
1178
|
+
if (created.idempotent) {
|
|
1179
|
+
sendJson(res, 202, {
|
|
1180
|
+
ok: true,
|
|
1181
|
+
accepted: true,
|
|
1182
|
+
idempotent: true,
|
|
1183
|
+
turnId: turn.turnId,
|
|
1184
|
+
ordinal: turn.ordinal,
|
|
1185
|
+
state: turn.state,
|
|
1186
|
+
turn: turnSummary(turn),
|
|
1187
|
+
});
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const controller = new AbortController();
|
|
1191
|
+
const execution = deps.turnExecutions?.register({
|
|
1192
|
+
sessionId,
|
|
1193
|
+
turnId: turn.turnId,
|
|
1194
|
+
clientRequestId,
|
|
1195
|
+
controller,
|
|
1196
|
+
});
|
|
1106
1197
|
deps.events.setTurnState(sessionId, turn.turnId, "running");
|
|
1198
|
+
const running = deps.events.getTurn(sessionId, turn.turnId) ?? {
|
|
1199
|
+
...turn,
|
|
1200
|
+
state: "running",
|
|
1201
|
+
};
|
|
1107
1202
|
sendJson(res, 202, {
|
|
1108
1203
|
ok: true,
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1204
|
+
accepted: true,
|
|
1205
|
+
idempotent: false,
|
|
1206
|
+
turnId: running.turnId,
|
|
1207
|
+
ordinal: running.ordinal,
|
|
1208
|
+
state: running.state,
|
|
1209
|
+
turn: turnSummary(running),
|
|
1112
1210
|
});
|
|
1113
1211
|
// Deliberately detached from the request lifecycle: closing the POST
|
|
1114
1212
|
// response cannot abort the prompt; clients follow GET /events instead.
|
|
1213
|
+
// Abort ownership lives in the execution registry (real Stop path).
|
|
1115
1214
|
void (async () => {
|
|
1116
1215
|
const { runChatTurn } = await import("./session-store.js");
|
|
1117
1216
|
let firstUserText;
|
|
1217
|
+
let terminalState = "failed";
|
|
1218
|
+
let terminalError;
|
|
1118
1219
|
try {
|
|
1119
1220
|
const outcome = await runChatTurn({
|
|
1120
1221
|
runtime: deps.runtime,
|
|
@@ -1123,6 +1224,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1123
1224
|
sessionId,
|
|
1124
1225
|
text,
|
|
1125
1226
|
images,
|
|
1227
|
+
signal: controller.signal,
|
|
1126
1228
|
onUserMessagePersisted: (record) => {
|
|
1127
1229
|
const users = record.messages.filter((message) => message.role === "user");
|
|
1128
1230
|
if (users.length !== 1)
|
|
@@ -1147,24 +1249,142 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
|
|
|
1147
1249
|
},
|
|
1148
1250
|
});
|
|
1149
1251
|
ensureAgentEndEvent(deps, sessionId, turn.turnId);
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1252
|
+
if (controller.signal.aborted) {
|
|
1253
|
+
terminalState = "aborted";
|
|
1254
|
+
terminalError = {
|
|
1255
|
+
code: "CLIENT_ABORTED",
|
|
1256
|
+
message: "turn aborted by client",
|
|
1257
|
+
};
|
|
1258
|
+
deps.events.setTurnState(sessionId, turn.turnId, "aborted", terminalError);
|
|
1259
|
+
}
|
|
1260
|
+
else if (outcome.ok) {
|
|
1261
|
+
terminalState = "settled";
|
|
1262
|
+
deps.events.setTurnState(sessionId, turn.turnId, "settled");
|
|
1263
|
+
if (firstUserText)
|
|
1264
|
+
scheduleAutomaticTitle(deps, {
|
|
1265
|
+
sessionId,
|
|
1266
|
+
turnId: turn.turnId,
|
|
1267
|
+
firstUserText,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
terminalState = "failed";
|
|
1272
|
+
terminalError = outcome.error;
|
|
1273
|
+
deps.events.setTurnState(sessionId, turn.turnId, "failed", outcome.error);
|
|
1274
|
+
}
|
|
1153
1275
|
}
|
|
1154
1276
|
catch (error) {
|
|
1155
1277
|
const message = error instanceof Error ? error.message : String(error);
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1278
|
+
if (controller.signal.aborted) {
|
|
1279
|
+
terminalState = "aborted";
|
|
1280
|
+
terminalError = {
|
|
1281
|
+
code: "CLIENT_ABORTED",
|
|
1282
|
+
message: "turn aborted by client",
|
|
1283
|
+
};
|
|
1284
|
+
ensureAgentEndEvent(deps, sessionId, turn.turnId);
|
|
1285
|
+
deps.events.setTurnState(sessionId, turn.turnId, "aborted", terminalError);
|
|
1286
|
+
}
|
|
1287
|
+
else {
|
|
1288
|
+
terminalState = "failed";
|
|
1289
|
+
terminalError = { code: "CHAT_TURN_FAILED", message };
|
|
1290
|
+
deps.events.append(sessionId, turn.turnId, {
|
|
1291
|
+
kind: "error",
|
|
1292
|
+
data: { message },
|
|
1293
|
+
});
|
|
1294
|
+
ensureAgentEndEvent(deps, sessionId, turn.turnId);
|
|
1295
|
+
deps.events.setTurnState(sessionId, turn.turnId, "failed", terminalError);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
finally {
|
|
1299
|
+
execution?.resolveCompletion({
|
|
1300
|
+
ok: terminalState === "settled",
|
|
1301
|
+
state: terminalState,
|
|
1302
|
+
error: terminalError,
|
|
1164
1303
|
});
|
|
1304
|
+
deps.turnExecutions?.clear(sessionId, turn.turnId);
|
|
1165
1305
|
}
|
|
1166
1306
|
})();
|
|
1167
1307
|
}
|
|
1308
|
+
/** Real server-side abort: invokes Pi session.abort via the execution registry. */
|
|
1309
|
+
export async function handleAbortChatTurn(req, res, deps, sessionId, turnId) {
|
|
1310
|
+
const gate = gateMutation(req, deps);
|
|
1311
|
+
if (!gate.ok) {
|
|
1312
|
+
sendJson(res, gate.status, {
|
|
1313
|
+
ok: false,
|
|
1314
|
+
accepted: false,
|
|
1315
|
+
error: { code: gate.code, message: gate.message },
|
|
1316
|
+
});
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const turn = deps.events.getTurn(sessionId, turnId);
|
|
1320
|
+
if (!turn) {
|
|
1321
|
+
sendJson(res, 404, {
|
|
1322
|
+
ok: false,
|
|
1323
|
+
accepted: false,
|
|
1324
|
+
error: {
|
|
1325
|
+
code: "NOT_FOUND",
|
|
1326
|
+
message: `chat turn not found: ${turnId}`,
|
|
1327
|
+
},
|
|
1328
|
+
});
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (isTerminalTurnState(turn.state)) {
|
|
1332
|
+
sendJson(res, 200, {
|
|
1333
|
+
ok: true,
|
|
1334
|
+
accepted: true,
|
|
1335
|
+
idempotent: true,
|
|
1336
|
+
turn: turnSummary(turn),
|
|
1337
|
+
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
const execution = deps.turnExecutions?.get(sessionId, turnId);
|
|
1341
|
+
if (!execution) {
|
|
1342
|
+
// Active durable Turn without process ownership (restart orphan / detached).
|
|
1343
|
+
const closed = deps.events.setTurnState(sessionId, turnId, "failed", {
|
|
1344
|
+
code: "CHAT_TURN_OWNER_LOST",
|
|
1345
|
+
message: "turn ownership lost; cannot abort a turn this process does not own",
|
|
1346
|
+
});
|
|
1347
|
+
sendJson(res, 409, {
|
|
1348
|
+
ok: false,
|
|
1349
|
+
accepted: false,
|
|
1350
|
+
error: {
|
|
1351
|
+
code: "CHAT_TURN_OWNER_LOST",
|
|
1352
|
+
message: "turn ownership lost; cannot abort a turn this process does not own",
|
|
1353
|
+
},
|
|
1354
|
+
turn: closed ? turnSummary(closed) : turnSummary(turn),
|
|
1355
|
+
});
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
deps.events.markAbortRequested?.(sessionId, turnId);
|
|
1359
|
+
if (!execution.controller.signal.aborted)
|
|
1360
|
+
execution.controller.abort();
|
|
1361
|
+
const waited = await (deps.turnExecutions?.waitForCompletion(sessionId, turnId, DEFAULT_ABORT_WAIT_MS) ?? Promise.resolve({ status: "missing" }));
|
|
1362
|
+
if (waited.status === "completed") {
|
|
1363
|
+
const finalTurn = deps.events.getTurn(sessionId, turnId) ??
|
|
1364
|
+
{
|
|
1365
|
+
...turn,
|
|
1366
|
+
state: waited.outcome.state,
|
|
1367
|
+
};
|
|
1368
|
+
sendJson(res, 200, {
|
|
1369
|
+
ok: true,
|
|
1370
|
+
accepted: true,
|
|
1371
|
+
idempotent: false,
|
|
1372
|
+
turn: turnSummary(finalTurn),
|
|
1373
|
+
});
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
// Timeout or missing completion: keep non-terminal; client reconciles via /state.
|
|
1377
|
+
const pending = deps.events.getTurn(sessionId, turnId) ?? turn;
|
|
1378
|
+
sendJson(res, 202, {
|
|
1379
|
+
ok: false,
|
|
1380
|
+
accepted: false,
|
|
1381
|
+
error: {
|
|
1382
|
+
code: "TURN_ABORT_PENDING",
|
|
1383
|
+
message: "abort requested; turn has not yet reached a terminal state",
|
|
1384
|
+
},
|
|
1385
|
+
turn: turnSummary(pending),
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1168
1388
|
export async function handleGetChatTurn(_req, res, deps, sessionId, turnId) {
|
|
1169
1389
|
const turn = deps.events.getTurn(sessionId, turnId);
|
|
1170
1390
|
if (!turn) {
|
|
@@ -1636,6 +1856,159 @@ export async function ensureActiveSession(deps, sessionId) {
|
|
|
1636
1856
|
}
|
|
1637
1857
|
return { ok: true };
|
|
1638
1858
|
}
|
|
1859
|
+
/**
|
|
1860
|
+
* PATCH /sessions/:id/skills/:resourceId/auto-invocation
|
|
1861
|
+
* Persist repo-scoped preference, then try immediate reloadSession when idle.
|
|
1862
|
+
* Busy sessions keep preferenceSaved=true and reloadApplied=false (no abort).
|
|
1863
|
+
*/
|
|
1864
|
+
export async function handleSkillAutoInvocation(req, res, deps, sessionId, resourceId) {
|
|
1865
|
+
const gate = gateMutation(req, deps);
|
|
1866
|
+
if (!gate.ok) {
|
|
1867
|
+
sendJson(res, gate.status, {
|
|
1868
|
+
ok: false,
|
|
1869
|
+
error: { code: gate.code, message: gate.message },
|
|
1870
|
+
});
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const ensured = await ensureActiveSession(deps, sessionId);
|
|
1874
|
+
if (!ensured.ok) {
|
|
1875
|
+
sendJson(res, ensured.status, {
|
|
1876
|
+
ok: false,
|
|
1877
|
+
error: { code: ensured.code, message: ensured.message },
|
|
1878
|
+
});
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
const id = resourceId.trim();
|
|
1882
|
+
if (!id) {
|
|
1883
|
+
sendJson(res, 400, {
|
|
1884
|
+
ok: false,
|
|
1885
|
+
error: {
|
|
1886
|
+
code: "INVALID_INPUT",
|
|
1887
|
+
message: "resourceId is required",
|
|
1888
|
+
},
|
|
1889
|
+
});
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
let body;
|
|
1893
|
+
try {
|
|
1894
|
+
body = await readJsonBody(req);
|
|
1895
|
+
}
|
|
1896
|
+
catch (error) {
|
|
1897
|
+
sendJson(res, 400, {
|
|
1898
|
+
ok: false,
|
|
1899
|
+
error: {
|
|
1900
|
+
code: "INVALID_INPUT",
|
|
1901
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1902
|
+
},
|
|
1903
|
+
});
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (typeof body.autoInvocationEnabled !== "boolean") {
|
|
1907
|
+
sendJson(res, 400, {
|
|
1908
|
+
ok: false,
|
|
1909
|
+
error: {
|
|
1910
|
+
code: "INVALID_INPUT",
|
|
1911
|
+
message: "autoInvocationEnabled boolean is required",
|
|
1912
|
+
},
|
|
1913
|
+
});
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const found = await deps.runtime.findSkillByResourceId(sessionId, id);
|
|
1917
|
+
if (!found) {
|
|
1918
|
+
sendJson(res, 404, {
|
|
1919
|
+
ok: false,
|
|
1920
|
+
error: {
|
|
1921
|
+
code: "PI_SKILL_NOT_FOUND",
|
|
1922
|
+
message: `skill resourceId not in current session inventory: ${id}`,
|
|
1923
|
+
},
|
|
1924
|
+
});
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
const prefsPath = deps.appData.resourcePreferences;
|
|
1928
|
+
if (!prefsPath) {
|
|
1929
|
+
sendJson(res, 500, {
|
|
1930
|
+
ok: false,
|
|
1931
|
+
error: {
|
|
1932
|
+
code: "PI_RESOURCE_PREFERENCES_WRITE_FAILED",
|
|
1933
|
+
message: "resourcePreferences path is not configured",
|
|
1934
|
+
},
|
|
1935
|
+
});
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
try {
|
|
1939
|
+
await setSkillAutoInvocationPreference({
|
|
1940
|
+
filePath: prefsPath,
|
|
1941
|
+
resourceId: id,
|
|
1942
|
+
autoInvocationEnabled: body.autoInvocationEnabled,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
catch (error) {
|
|
1946
|
+
if (error instanceof ResourcePreferencesError) {
|
|
1947
|
+
const status = error.code === "PI_RESOURCE_PREFERENCES_MALFORMED" ? 500 : 500;
|
|
1948
|
+
sendJson(res, status, {
|
|
1949
|
+
ok: false,
|
|
1950
|
+
error: { code: error.code, message: error.message },
|
|
1951
|
+
});
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
sendJson(res, 500, {
|
|
1955
|
+
ok: false,
|
|
1956
|
+
error: {
|
|
1957
|
+
code: "PI_RESOURCE_PREFERENCES_WRITE_FAILED",
|
|
1958
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1959
|
+
},
|
|
1960
|
+
});
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
const reload = await deps.runtime.reloadSession(sessionId);
|
|
1964
|
+
if (!reload.ok) {
|
|
1965
|
+
if (reload.code === "PI_SESSION_BUSY") {
|
|
1966
|
+
// Preference is durable; do not abort the busy turn or pretend reload applied.
|
|
1967
|
+
sendJson(res, 200, {
|
|
1968
|
+
ok: true,
|
|
1969
|
+
preferenceSaved: true,
|
|
1970
|
+
reloadApplied: false,
|
|
1971
|
+
error: { code: reload.code, message: reload.message },
|
|
1972
|
+
});
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
sendJson(res, reload.code === "PI_SESSION_NOT_FOUND" ? 404 : 500, {
|
|
1976
|
+
ok: false,
|
|
1977
|
+
preferenceSaved: true,
|
|
1978
|
+
reloadApplied: false,
|
|
1979
|
+
error: { code: reload.code, message: reload.message },
|
|
1980
|
+
});
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
let snapshot;
|
|
1984
|
+
try {
|
|
1985
|
+
snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
|
|
1986
|
+
}
|
|
1987
|
+
catch (error) {
|
|
1988
|
+
if (sendRuntimeUnavailable(res, error))
|
|
1989
|
+
return;
|
|
1990
|
+
throw error;
|
|
1991
|
+
}
|
|
1992
|
+
if (!snapshot) {
|
|
1993
|
+
sendJson(res, 503, {
|
|
1994
|
+
ok: false,
|
|
1995
|
+
preferenceSaved: true,
|
|
1996
|
+
reloadApplied: true,
|
|
1997
|
+
error: {
|
|
1998
|
+
code: "PI_RUNTIME_UNAVAILABLE",
|
|
1999
|
+
message: `chat session not active after reload: ${sessionId}`,
|
|
2000
|
+
},
|
|
2001
|
+
});
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
sendJson(res, 200, {
|
|
2005
|
+
ok: true,
|
|
2006
|
+
preferenceSaved: true,
|
|
2007
|
+
reloadApplied: true,
|
|
2008
|
+
revision: reload.revision,
|
|
2009
|
+
snapshot,
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
1639
2012
|
/**
|
|
1640
2013
|
* POST /sessions/:id/reload (AC-05 / AC-06): official session.reload() with
|
|
1641
2014
|
* structured busy/failure semantics. Never aborts, never silently queues.
|
|
@@ -2551,6 +2924,11 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2551
2924
|
await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
|
|
2552
2925
|
return true;
|
|
2553
2926
|
}
|
|
2927
|
+
const skillAutoInvocation = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/skills\/([^/]+)\/auto-invocation$/);
|
|
2928
|
+
if (method === "PATCH" && skillAutoInvocation) {
|
|
2929
|
+
await handleSkillAutoInvocation(req, res, deps, decodeURIComponent(skillAutoInvocation[1]), decodeURIComponent(skillAutoInvocation[2]));
|
|
2930
|
+
return true;
|
|
2931
|
+
}
|
|
2554
2932
|
const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|runtime-snapshot|models|model|reload)$/);
|
|
2555
2933
|
if (m4Route) {
|
|
2556
2934
|
const id = decodeURIComponent(m4Route[1]);
|
|
@@ -2609,6 +2987,11 @@ export async function handleChatRequest(req, res, deps, pathname) {
|
|
|
2609
2987
|
await handleCreateChatTurn(req, res, deps, decodeURIComponent(sessionTurn[1]));
|
|
2610
2988
|
return true;
|
|
2611
2989
|
}
|
|
2990
|
+
const sessionTurnAbort = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns\/([^/]+)\/abort$/);
|
|
2991
|
+
if (method === "POST" && sessionTurnAbort) {
|
|
2992
|
+
await handleAbortChatTurn(req, res, deps, decodeURIComponent(sessionTurnAbort[1]), decodeURIComponent(sessionTurnAbort[2]));
|
|
2993
|
+
return true;
|
|
2994
|
+
}
|
|
2612
2995
|
const sessionTurnDetail = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns\/([^/]+)$/);
|
|
2613
2996
|
if (method === "GET" && sessionTurnDetail) {
|
|
2614
2997
|
await handleGetChatTurn(req, res, deps, decodeURIComponent(sessionTurnDetail[1]), decodeURIComponent(sessionTurnDetail[2]));
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped ownership of in-flight Chat Turn executions.
|
|
3
|
+
*
|
|
4
|
+
* Registry entries must exist before HTTP 202 is returned, and every
|
|
5
|
+
* completion path (success / abort / failure) must clear them in finally so
|
|
6
|
+
* durable active leases cannot outlive the owning process.
|
|
7
|
+
*/
|
|
8
|
+
function keyFor(sessionId, turnId) {
|
|
9
|
+
return `${sessionId}\0${turnId}`;
|
|
10
|
+
}
|
|
11
|
+
export function createChatTurnExecutionRegistry() {
|
|
12
|
+
const entries = new Map();
|
|
13
|
+
const bySession = new Map();
|
|
14
|
+
return {
|
|
15
|
+
register(input) {
|
|
16
|
+
const existingSession = bySession.get(input.sessionId);
|
|
17
|
+
if (existingSession && existingSession !== input.turnId) {
|
|
18
|
+
throw new Error(`CHAT_TURN_EXECUTION_ACTIVE: ${input.sessionId} already owns ${existingSession}`);
|
|
19
|
+
}
|
|
20
|
+
const key = keyFor(input.sessionId, input.turnId);
|
|
21
|
+
const prior = entries.get(key);
|
|
22
|
+
if (prior)
|
|
23
|
+
return prior;
|
|
24
|
+
let resolveCompletion;
|
|
25
|
+
const completion = new Promise((resolve) => {
|
|
26
|
+
resolveCompletion = resolve;
|
|
27
|
+
});
|
|
28
|
+
// Prevent unhandled rejection if nobody awaits.
|
|
29
|
+
void completion.catch(() => undefined);
|
|
30
|
+
const entry = {
|
|
31
|
+
sessionId: input.sessionId,
|
|
32
|
+
turnId: input.turnId,
|
|
33
|
+
clientRequestId: input.clientRequestId,
|
|
34
|
+
controller: input.controller ?? new AbortController(),
|
|
35
|
+
completion,
|
|
36
|
+
resolveCompletion,
|
|
37
|
+
};
|
|
38
|
+
entries.set(key, entry);
|
|
39
|
+
bySession.set(input.sessionId, input.turnId);
|
|
40
|
+
return entry;
|
|
41
|
+
},
|
|
42
|
+
get(sessionId, turnId) {
|
|
43
|
+
return entries.get(keyFor(sessionId, turnId));
|
|
44
|
+
},
|
|
45
|
+
getBySession(sessionId) {
|
|
46
|
+
const turnId = bySession.get(sessionId);
|
|
47
|
+
if (!turnId)
|
|
48
|
+
return undefined;
|
|
49
|
+
return entries.get(keyFor(sessionId, turnId));
|
|
50
|
+
},
|
|
51
|
+
clear(sessionId, turnId) {
|
|
52
|
+
const key = keyFor(sessionId, turnId);
|
|
53
|
+
entries.delete(key);
|
|
54
|
+
if (bySession.get(sessionId) === turnId)
|
|
55
|
+
bySession.delete(sessionId);
|
|
56
|
+
},
|
|
57
|
+
async waitForCompletion(sessionId, turnId, timeoutMs) {
|
|
58
|
+
const entry = entries.get(keyFor(sessionId, turnId));
|
|
59
|
+
if (!entry)
|
|
60
|
+
return { status: "missing" };
|
|
61
|
+
let timer;
|
|
62
|
+
try {
|
|
63
|
+
const outcome = await Promise.race([
|
|
64
|
+
entry.completion.then((value) => ({ kind: "done", value })),
|
|
65
|
+
new Promise((resolve) => {
|
|
66
|
+
timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
|
67
|
+
}),
|
|
68
|
+
]);
|
|
69
|
+
if (outcome.kind === "timeout")
|
|
70
|
+
return { status: "timeout" };
|
|
71
|
+
return { status: "completed", outcome: outcome.value };
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (timer)
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
size() {
|
|
79
|
+
return entries.size;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -22,6 +22,19 @@ import path from "node:path";
|
|
|
22
22
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
23
23
|
import { readJsonIfExists, sha256Utf8, writeSecureJson, } from "./app-data.js";
|
|
24
24
|
import { hashDagBytes, } from "./dag-confirmation.js";
|
|
25
|
+
/**
|
|
26
|
+
* Structured verification command patterns for the G2 autonomous-execution
|
|
27
|
+
* gate. A DAG is only eligible for autonomous execution when it carries at
|
|
28
|
+
* least one shell verification node whose id or command is recognized as a
|
|
29
|
+
* real test/verification suite (not just an arbitrary shell command).
|
|
30
|
+
*
|
|
31
|
+
* Covers the Console workflow kinds: standard/frontend (npm run test/lint/
|
|
32
|
+
* typecheck, vitest, check-repo.sh), backend-test (pytest) and frontend-test
|
|
33
|
+
* (playwright). Ids ending in -verify / -verification / -validate / -gate are
|
|
34
|
+
* treated as verification shells by the caller; commands broaden the match to
|
|
35
|
+
* framework invocations that don't embed the word "verify".
|
|
36
|
+
*/
|
|
37
|
+
export const STRUCTURED_VERIFICATION_COMMAND_RE = /\b(vitest|jest|playwright|cypress|pytest|py-test|go\s+test|cargo\s+test|npm\s+run\s+(lint|typecheck|test|verify)|npm\s+(test|run\s+test)|check-repo\.sh|loop-agent-standard-verify)\b/i;
|
|
25
38
|
export const DEFAULT_EXECUTION_RECEIPT_TTL_MS = 30 * 60 * 1000;
|
|
26
39
|
function normalizePathEntry(entry) {
|
|
27
40
|
return entry.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -145,7 +158,7 @@ export function deriveEligibilityFactsFromDagSpec(input) {
|
|
|
145
158
|
if (/verify|verification/i.test(task.id ?? ""))
|
|
146
159
|
return true;
|
|
147
160
|
const commands = stringList(task.shell.commands);
|
|
148
|
-
return commands.some((command) =>
|
|
161
|
+
return commands.some((command) => STRUCTURED_VERIFICATION_COMMAND_RE.test(command));
|
|
149
162
|
});
|
|
150
163
|
return {
|
|
151
164
|
writersWriteSets: writers,
|