@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.
@@ -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: `chat turn already active: ${createdTurn.activeTurn.turnId}`,
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 created = deps.events.createTurn(sessionId);
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: `chat turn already active: ${created.activeTurn.turnId}`,
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
- turnId: turn.turnId,
1110
- ordinal: turn.ordinal,
1111
- state: "running",
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
- deps.events.setTurnState(sessionId, turn.turnId, outcome.ok ? "settled" : "failed", outcome.error);
1151
- if (outcome.ok && firstUserText)
1152
- scheduleAutomaticTitle(deps, { sessionId, turnId: turn.turnId, firstUserText });
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
- deps.events.append(sessionId, turn.turnId, {
1157
- kind: "error",
1158
- data: { message },
1159
- });
1160
- ensureAgentEndEvent(deps, sessionId, turn.turnId);
1161
- deps.events.setTurnState(sessionId, turn.turnId, "failed", {
1162
- code: "CHAT_TURN_FAILED",
1163
- message,
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) => /(vitest|npm run (lint|typecheck|test)|check-repo\.sh|loop-agent-standard-verify)/.test(command));
161
+ return commands.some((command) => STRUCTURED_VERIFICATION_COMMAND_RE.test(command));
149
162
  });
150
163
  return {
151
164
  writersWriteSets: writers,