@sleep2agi/commhub-server 0.9.0-preview.30 → 0.9.0-preview.32

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.
@@ -770,6 +770,12 @@ export class SideThreadCoordinator {
770
770
  if (existing) {
771
771
  if (existing.fingerprint !== fingerprint)
772
772
  throw conflict("idempotency key reused with different create payload");
773
+ if (this.createNeedsReconciliation(existing.record)) {
774
+ return this.singleFlight(
775
+ `create:${actor.userId}:${input.requestKey}`,
776
+ () => this.reconcileCreate(actor, input, attachments, fingerprint),
777
+ );
778
+ }
773
779
  return existing.record;
774
780
  }
775
781
  return this.singleFlight(
@@ -781,6 +787,8 @@ export class SideThreadCoordinator {
781
787
  throw conflict(
782
788
  "idempotency key reused with different create payload",
783
789
  );
790
+ if (this.createNeedsReconciliation(replay.record))
791
+ return this.reconcileCreate(actor, input, attachments, fingerprint);
784
792
  return replay.record;
785
793
  }
786
794
  const capability = await this.assertCapability(
@@ -880,7 +888,7 @@ export class SideThreadCoordinator {
880
888
  throw runtimeProtocol(
881
889
  "runtime returned source thread as side thread",
882
890
  );
883
- this.store.db.transaction(() => {
891
+ this.applyRuntimeReceipt(() => {
884
892
  const t = this.now();
885
893
  this.store.db.run(
886
894
  "UPDATE side_chats SET derived_thread_id=?1,updated_at=?2 WHERE side_chat_id=?3 AND state='creating'",
@@ -921,16 +929,18 @@ export class SideThreadCoordinator {
921
929
  attachments,
922
930
  });
923
931
  requireRuntimeIdentity(started.turnId, "turnId");
924
- this.store.db.transaction(() => {
932
+ const attemptBeforeApply = this.store.db.get<any>(
933
+ "SELECT state,turn_id FROM side_chat_attempts WHERE attempt_id=?1",
934
+ attemptId,
935
+ );
936
+ if (!attemptBeforeApply) throw conflict("attempt disappeared during start");
937
+ if (attemptBeforeApply.turn_id && attemptBeforeApply.turn_id !== started.turnId)
938
+ throw runtimeProtocol("runtime turn identity changed during start");
939
+ this.applyRuntimeReceipt(() => {
925
940
  const attempt = this.store.db.get<any>(
926
941
  "SELECT state,turn_id FROM side_chat_attempts WHERE attempt_id=?1",
927
942
  attemptId,
928
943
  );
929
- if (!attempt) throw conflict("attempt disappeared during start");
930
- if (attempt.turn_id && attempt.turn_id !== started.turnId)
931
- throw runtimeProtocol(
932
- "runtime turn identity changed during start",
933
- );
934
944
  this.settleOperation(startOperationId, "completed", {
935
945
  threadId: forked.threadId,
936
946
  turnId: started.turnId,
@@ -964,10 +974,7 @@ export class SideThreadCoordinator {
964
974
  this.markAmbiguous(sideChatId, attemptId, activeOperationId, error);
965
975
  throw ambiguousError(activeOperationId, sideChatId, attemptId);
966
976
  }
967
- this.settleOperation(activeOperationId, "failed", {
968
- errorCode: auditErrorReason(error),
969
- });
970
- this.failStarting(sideChatId, attemptId, error);
977
+ this.failStarting(sideChatId, attemptId, error, activeOperationId);
971
978
  throw normalizePortError(error);
972
979
  }
973
980
  return this.mustOwned(sideChatId, actor);
@@ -975,6 +982,132 @@ export class SideThreadCoordinator {
975
982
  );
976
983
  }
977
984
 
985
+ /**
986
+ * Resume the stable fork/start operations after an ACK arrived outside the
987
+ * request window. All identity needed to resume lives in SQLite; calling
988
+ * create again from another process therefore consumes the durable receipt
989
+ * instead of issuing a second native operation.
990
+ */
991
+ private async reconcileCreate(
992
+ actor: SideThreadActor,
993
+ input: CreateInput,
994
+ attachments: SideThreadAttachmentRef[],
995
+ fingerprint: string,
996
+ ): Promise<SideThreadRecord> {
997
+ const found = this.store.getByCreateKey(actor, input.requestKey);
998
+ if (!found) throw conflict("create record disappeared during reconciliation");
999
+ if (found.fingerprint !== fingerprint)
1000
+ throw conflict("idempotency key reused with different create payload");
1001
+ const sideChatId = found.record.sideChatId;
1002
+ const attemptId = found.record.activeAttemptId;
1003
+ if (!attemptId) throw conflict("create attempt disappeared during reconciliation");
1004
+ const forkOperationId = stableOperationId(sideChatId, "fork", input.requestKey);
1005
+ const startOperationId = stableOperationId(sideChatId, "start", input.requestKey);
1006
+ let activeOperationId = forkOperationId;
1007
+ try {
1008
+ this.applyRuntimeReceipt(() => {
1009
+ const at = this.now();
1010
+ this.store.db.run(
1011
+ "UPDATE side_chats SET state='reconciling',updated_at=?1 WHERE side_chat_id=?2 AND state IN ('creating','ambiguous')",
1012
+ [at, sideChatId],
1013
+ );
1014
+ this.store.db.run(
1015
+ "UPDATE side_chat_attempts SET state='reconciling',updated_at=?1 WHERE attempt_id=?2 AND state IN ('starting','ambiguous')",
1016
+ [at, attemptId],
1017
+ );
1018
+ this.store.db.run(
1019
+ "UPDATE side_chat_operations SET state='reconciling',updated_at=?1 WHERE operation_id=?2 AND state='ambiguous'",
1020
+ [at, forkOperationId],
1021
+ );
1022
+ });
1023
+ const forkOp = this.store.db.get<any>(
1024
+ "SELECT state,thread_id FROM side_chat_operations WHERE operation_id=?1",
1025
+ forkOperationId,
1026
+ );
1027
+ let threadId = forkOp?.state === "completed" ? forkOp.thread_id : undefined;
1028
+ if (!threadId) {
1029
+ const forked = await this.port.fork({
1030
+ operationId: forkOperationId,
1031
+ requestKey: input.requestKey,
1032
+ sideChatId,
1033
+ nodeId: input.nodeId,
1034
+ sourceThreadId: input.sourceThreadId,
1035
+ boundary: input.boundary,
1036
+ });
1037
+ threadId = forked.threadId;
1038
+ }
1039
+ requireRuntimeIdentity(threadId, "threadId");
1040
+ if (threadId === input.sourceThreadId)
1041
+ throw runtimeProtocol("runtime returned source thread as side thread");
1042
+ this.applyRuntimeReceipt(() => {
1043
+ const at = this.now();
1044
+ this.settleOperation(forkOperationId, "completed", { threadId });
1045
+ this.store.db.run(
1046
+ "UPDATE side_chats SET derived_thread_id=?1,updated_at=?2 WHERE side_chat_id=?3 AND state IN ('creating','ambiguous','reconciling')",
1047
+ [threadId, at, sideChatId],
1048
+ );
1049
+ this.store.db.run(
1050
+ "UPDATE side_chat_attempts SET thread_id=?1,updated_at=?2 WHERE attempt_id=?3 AND state IN ('starting','ambiguous','reconciling')",
1051
+ [threadId, at, attemptId],
1052
+ );
1053
+ const existingStart = this.store.db.get<any>(
1054
+ "SELECT operation_id FROM side_chat_operations WHERE operation_id=?1",
1055
+ startOperationId,
1056
+ );
1057
+ if (!existingStart)
1058
+ this.insertOperation({ operationId: startOperationId, sideChatId, attemptId, kind: "start", requestKey: input.requestKey, fingerprint, threadId, at });
1059
+ else
1060
+ this.store.db.run(
1061
+ "UPDATE side_chat_operations SET state='reconciling',updated_at=?1 WHERE operation_id=?2 AND state='ambiguous'",
1062
+ [at, startOperationId],
1063
+ );
1064
+ });
1065
+ activeOperationId = startOperationId;
1066
+ const startOp = this.store.db.get<any>(
1067
+ "SELECT state,turn_id FROM side_chat_operations WHERE operation_id=?1",
1068
+ startOperationId,
1069
+ );
1070
+ let turnId = startOp?.state === "completed" ? startOp.turn_id : undefined;
1071
+ if (!turnId) {
1072
+ const started = await this.port.start({ operationId: startOperationId, requestKey: input.requestKey, sideChatId, attemptId, nodeId: input.nodeId, threadId, prompt: input.prompt, attachments });
1073
+ turnId = started.turnId;
1074
+ }
1075
+ requireRuntimeIdentity(turnId, "turnId");
1076
+ this.applyRuntimeReceipt(() => {
1077
+ const at = this.now();
1078
+ this.settleOperation(startOperationId, "completed", { threadId, turnId });
1079
+ this.store.db.run(
1080
+ "UPDATE side_chat_attempts SET state='running',thread_id=?1,turn_id=?2,updated_at=?3 WHERE attempt_id=?4 AND state IN ('starting','ambiguous','reconciling')",
1081
+ [threadId, turnId, at, attemptId],
1082
+ );
1083
+ this.store.db.run(
1084
+ "UPDATE side_chats SET state='running',derived_thread_id=?1,updated_at=?2 WHERE side_chat_id=?3 AND active_attempt_id=?4 AND state IN ('creating','ambiguous','reconciling')",
1085
+ [threadId, at, sideChatId, attemptId],
1086
+ );
1087
+ });
1088
+ return this.mustOwned(sideChatId, actor);
1089
+ } catch (error) {
1090
+ if (isAmbiguousRuntimeError(error)) {
1091
+ if (this.operationState(activeOperationId) === "completed")
1092
+ return this.mustOwned(sideChatId, actor);
1093
+ this.markAmbiguous(sideChatId, attemptId, activeOperationId, error);
1094
+ throw ambiguousError(activeOperationId, sideChatId, attemptId);
1095
+ }
1096
+ this.failStarting(sideChatId, attemptId, error, activeOperationId);
1097
+ throw normalizePortError(error);
1098
+ }
1099
+ }
1100
+
1101
+ private createNeedsReconciliation(record: SideThreadRecord): boolean {
1102
+ if (["creating", "ambiguous", "reconciling"].includes(record.state)) return true;
1103
+ const active = record.attempts.find((attempt) => attempt.attemptId === record.activeAttemptId);
1104
+ if (active && ["starting", "ambiguous", "reconciling"].includes(active.state)) return true;
1105
+ return record.operations.some((operation) =>
1106
+ (operation.kind === "fork" || operation.kind === "start") &&
1107
+ ["pending", "ambiguous", "reconciling"].includes(operation.state),
1108
+ );
1109
+ }
1110
+
978
1111
  get(actor: SideThreadActor, sideChatId: string): SideThreadRecord {
979
1112
  this.assertEnabled();
980
1113
  this.assertActor(actor);
@@ -1042,19 +1175,35 @@ export class SideThreadCoordinator {
1042
1175
  operation_id: string;
1043
1176
  attempt_id: string | null;
1044
1177
  state: SideThreadOperationRecord["state"];
1178
+ error_code: string | null;
1045
1179
  }>(
1046
- "SELECT operation_id,attempt_id,state FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind='cancel' AND request_key=?2",
1180
+ "SELECT operation_id,attempt_id,state,error_code FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind='cancel' AND request_key=?2",
1047
1181
  sideChatId,
1048
1182
  input.requestKey,
1049
1183
  );
1050
1184
  if (replay?.state === "completed" || replay?.state === "pending")
1051
1185
  return record;
1052
- if (replay?.state === "ambiguous" || replay?.state === "reconciling")
1053
- throw ambiguousError(
1054
- replay.operation_id,
1055
- sideChatId,
1056
- replay.attempt_id ?? undefined,
1057
- );
1186
+ if (replay?.state === "failed") throw persistedOperationFailure(replay.error_code);
1187
+ if (replay?.state === "ambiguous" || replay?.state === "reconciling") {
1188
+ const attempt = record.attempts.find((a) => a.attemptId === replay.attempt_id);
1189
+ if (!attempt?.threadId || !attempt.turnId) throw conflict("cancel ownership missing");
1190
+ await this.reconcileLifecycleOperation({
1191
+ sideChatId, attemptId: attempt.attemptId, operationId: replay.operation_id,
1192
+ invoke: () => this.port.cancel({ operationId: replay.operation_id, sideChatId, attemptId: attempt.attemptId, nodeId: record.nodeId, threadId: attempt.threadId!, turnId: attempt.turnId! }),
1193
+ receipt: () => ({ threadId: attempt.threadId, turnId: attempt.turnId }),
1194
+ accepted: () => {
1195
+ const t = this.now();
1196
+ this.store.db.run("UPDATE side_chat_attempts SET state='running',updated_at=?1 WHERE attempt_id=?2 AND state IN ('ambiguous','reconciling')", [t, attempt.attemptId]);
1197
+ this.store.db.run("UPDATE side_chats SET state='running',updated_at=?1 WHERE side_chat_id=?2 AND active_attempt_id=?3 AND state IN ('ambiguous','reconciling')", [t, sideChatId, attempt.attemptId]);
1198
+ },
1199
+ failed: () => {
1200
+ const t = this.now();
1201
+ this.store.db.run("UPDATE side_chat_attempts SET state='running',cancel_requested_at=NULL,updated_at=?1 WHERE attempt_id=?2 AND state IN ('ambiguous','reconciling')", [t, attempt.attemptId]);
1202
+ this.store.db.run("UPDATE side_chats SET state='running',updated_at=?1 WHERE side_chat_id=?2 AND active_attempt_id=?3 AND state IN ('ambiguous','reconciling')", [t, sideChatId, attempt.attemptId]);
1203
+ },
1204
+ });
1205
+ return this.mustOwned(sideChatId, actor);
1206
+ }
1058
1207
  const attempt = record.attempts.find(
1059
1208
  (a) => a.attemptId === record.activeAttemptId,
1060
1209
  );
@@ -1141,27 +1290,8 @@ export class SideThreadCoordinator {
1141
1290
  throw normalizePortError(error);
1142
1291
  }
1143
1292
  this.store.db.transaction(() => {
1144
- const t = this.now();
1145
- const changed = this.store.db.run(
1146
- "UPDATE side_chat_attempts SET state='cancelled',updated_at=?1 WHERE attempt_id=?2 AND state='running' AND thread_id=?3 AND turn_id=?4",
1147
- [t, attempt.attemptId, attempt.threadId, attempt.turnId],
1148
- );
1149
- if (changed.changes > 0) {
1150
- this.store.db.run(
1151
- "UPDATE side_chats SET state='cancelled',active_attempt_id=NULL,updated_at=?1 WHERE side_chat_id=?2 AND active_attempt_id=?3",
1152
- [t, sideChatId, attempt.attemptId],
1153
- );
1154
- this.emitStored(
1155
- sideChatId,
1156
- attempt.attemptId,
1157
- attempt.threadId,
1158
- attempt.turnId,
1159
- "attempt.cancelled",
1160
- "cancelled",
1161
- undefined,
1162
- t,
1163
- );
1164
- }
1293
+ // Command acceptance only proves that interrupt was accepted. The
1294
+ // authoritative terminal event owns the cancelled/completed race.
1165
1295
  this.settleOperation(prepared.operationId, "completed", {
1166
1296
  threadId: attempt.threadId,
1167
1297
  turnId: attempt.turnId,
@@ -1337,10 +1467,7 @@ export class SideThreadCoordinator {
1337
1467
  this.markAmbiguous(sideChatId, attemptId, startOperationId, error);
1338
1468
  throw ambiguousError(startOperationId, sideChatId, attemptId);
1339
1469
  }
1340
- this.settleOperation(startOperationId, "failed", {
1341
- errorCode: auditErrorReason(error),
1342
- });
1343
- this.failStarting(sideChatId, attemptId, error);
1470
+ this.failStarting(sideChatId, attemptId, error, startOperationId);
1344
1471
  throw normalizePortError(error);
1345
1472
  }
1346
1473
  return this.mustOwned(sideChatId, actor);
@@ -1379,6 +1506,11 @@ export class SideThreadCoordinator {
1379
1506
  if (!r.threadId) throw conflict("side chat has no derived thread");
1380
1507
  if (r.state === "purged") return r;
1381
1508
  if (action === "archive" && r.state === "archived") return r;
1509
+ const priorOperation = this.store.db.get<{ state: string; error_code: string | null }>(
1510
+ "SELECT state,error_code FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind=?2 AND request_key=?3",
1511
+ sideChatId, action, input.requestKey,
1512
+ );
1513
+ if (priorOperation?.state === "failed") throw persistedOperationFailure(priorOperation.error_code);
1382
1514
  await this.assertCapability(r.nodeId);
1383
1515
  const prepared = this.prepareStableOperation({
1384
1516
  sideChatId,
@@ -1388,8 +1520,18 @@ export class SideThreadCoordinator {
1388
1520
  threadId: r.threadId,
1389
1521
  });
1390
1522
  if (prepared.state === "completed") return r;
1391
- if (prepared.state === "ambiguous" || prepared.state === "reconciling")
1392
- throw ambiguousError(prepared.operationId, sideChatId);
1523
+ if (prepared.state === "ambiguous" || prepared.state === "reconciling") {
1524
+ await this.reconcileLifecycleOperation({
1525
+ sideChatId, operationId: prepared.operationId,
1526
+ invoke: () => action === "archive"
1527
+ ? this.port.archive({ operationId: prepared.operationId, sideChatId, nodeId: r.nodeId, threadId: r.threadId! })
1528
+ : this.port.purge({ operationId: prepared.operationId, sideChatId, nodeId: r.nodeId, threadId: r.threadId! }),
1529
+ receipt: () => ({ threadId: r.threadId }),
1530
+ accepted: () => this.applyLifecycleAccepted(sideChatId, action, prepared.operationId, r.threadId!),
1531
+ failed: () => this.store.db.run("UPDATE side_chats SET state='failed',lifecycle_operation=NULL,updated_at=?1 WHERE side_chat_id=?2 AND state IN ('ambiguous','reconciling')", [this.now(), sideChatId]),
1532
+ });
1533
+ return this.mustOwned(sideChatId, actor);
1534
+ }
1393
1535
  if (!prepared.callable)
1394
1536
  throw conflict("side chat lifecycle operation already in progress");
1395
1537
  const claim = this.store.db.run(
@@ -1536,21 +1678,33 @@ export class SideThreadCoordinator {
1536
1678
  const operation = this.store.db.get<{
1537
1679
  operation_id: string;
1538
1680
  state: string;
1681
+ error_code: string | null;
1539
1682
  }>(
1540
- "SELECT operation_id,state FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind='bring-back' AND request_key=?2",
1683
+ "SELECT operation_id,state,error_code FROM side_chat_operations WHERE side_chat_id=?1 AND operation_kind='bring-back' AND request_key=?2",
1541
1684
  sideChatId,
1542
1685
  input.requestKey,
1543
1686
  );
1544
- if (
1545
- operation &&
1546
- (operation.state === "ambiguous" ||
1547
- operation.state === "reconciling")
1548
- )
1549
- throw ambiguousError(
1550
- operation.operation_id,
1551
- sideChatId,
1552
- attempt.attemptId,
1553
- );
1687
+ if (operation && (operation.state === "ambiguous" || operation.state === "reconciling")) {
1688
+ const destinationTurnId = await this.reconcileLifecycleOperation({
1689
+ sideChatId, attemptId: attempt.attemptId, operationId: operation.operation_id,
1690
+ invoke: async () => (await this.port.bringBack({ operationId: operation.operation_id, sideChatId, attemptId: attempt.attemptId, nodeId: r.nodeId, sourceThreadId: attempt.threadId!, sourceTurnId: attempt.turnId!, destinationThreadId: input.destinationThreadId, requestKey: input.requestKey, text: attempt.result! })).destinationTurnId,
1691
+ receipt: (turn) => ({ threadId: input.destinationThreadId, turnId: turn }),
1692
+ accepted: (turn) => {
1693
+ requireRuntimeIdentity(turn, "destinationTurnId");
1694
+ const now = this.now();
1695
+ this.store.db.run("UPDATE side_chat_bring_backs SET state='completed',destination_turn_id=?1,error_text=NULL,updated_at=?2,completed_at=?2 WHERE bring_back_id=?3 AND state IN ('starting','ambiguous','reconciling')", [turn, now, old.bring_back_id]);
1696
+ this.store.db.run("UPDATE side_chats SET state='completed',updated_at=?1 WHERE side_chat_id=?2 AND state IN ('ambiguous','reconciling')", [now, sideChatId]);
1697
+ this.emitStored(sideChatId, attempt.attemptId, attempt.threadId, attempt.turnId, "side_chat.brought_back", "completed", undefined, now);
1698
+ },
1699
+ failed: (error) => {
1700
+ const now = this.now();
1701
+ this.store.db.run("UPDATE side_chat_bring_backs SET state='failed',error_text=?1,updated_at=?2 WHERE bring_back_id=?3 AND state IN ('starting','ambiguous','reconciling')", [safeReason(error instanceof Error ? error.message : String(error)), now, old.bring_back_id]);
1702
+ this.store.db.run("UPDATE side_chats SET state='completed',updated_at=?1 WHERE side_chat_id=?2 AND state IN ('ambiguous','reconciling')", [now, sideChatId]);
1703
+ },
1704
+ });
1705
+ return { bringBackId: old.bring_back_id, destinationTurnId };
1706
+ }
1707
+ if (operation?.state === "failed") throw persistedOperationFailure(operation.error_code);
1554
1708
  throw conflict("bring-back is already in progress or failed");
1555
1709
  }
1556
1710
  return {
@@ -1723,6 +1877,84 @@ export class SideThreadCoordinator {
1723
1877
  );
1724
1878
  }
1725
1879
 
1880
+ private applyRuntimeReceipt(apply: () => void): void {
1881
+ try {
1882
+ this.store.db.transaction(apply);
1883
+ } catch {
1884
+ // The runtime receipt remains authoritative and durable; only the local
1885
+ // projection failed. Callers mark the stable operation ambiguous so a
1886
+ // restart can consume the same receipt without another native command.
1887
+ throw new SideThreadError(
1888
+ "SIDE_THREAD_AMBIGUOUS",
1889
+ "runtime receipt accepted; local apply requires reconciliation",
1890
+ 202,
1891
+ );
1892
+ }
1893
+ }
1894
+
1895
+ private async reconcileLifecycleOperation<T>(input: {
1896
+ sideChatId: string;
1897
+ attemptId?: string;
1898
+ operationId: string;
1899
+ invoke: () => Promise<T>;
1900
+ receipt: (value: T) => { threadId?: string; turnId?: string };
1901
+ accepted: (value: T) => void;
1902
+ failed: (error: unknown) => void;
1903
+ }): Promise<T> {
1904
+ this.store.db.run(
1905
+ "UPDATE side_chat_operations SET state='reconciling',updated_at=?1 WHERE operation_id=?2 AND state='ambiguous'",
1906
+ [this.now(), input.operationId],
1907
+ );
1908
+ let value: T;
1909
+ try {
1910
+ value = await input.invoke();
1911
+ } catch (error) {
1912
+ if (isAmbiguousRuntimeError(error)) {
1913
+ this.markAmbiguous(input.sideChatId, input.attemptId, input.operationId, error);
1914
+ throw ambiguousError(input.operationId, input.sideChatId, input.attemptId);
1915
+ }
1916
+ try {
1917
+ this.store.db.transaction(() => {
1918
+ const won = this.store.db.run(
1919
+ "UPDATE side_chat_operations SET state='failed',error_code=?1,updated_at=?2 WHERE operation_id=?3 AND state IN ('ambiguous','reconciling')",
1920
+ [auditErrorReason(error), this.now(), input.operationId],
1921
+ );
1922
+ if (won.changes === 1) input.failed(error);
1923
+ });
1924
+ } catch {
1925
+ this.markAmbiguous(input.sideChatId, input.attemptId, input.operationId, { code: "SIDE_THREAD_LOCAL_APPLY_FAILED" });
1926
+ throw ambiguousError(input.operationId, input.sideChatId, input.attemptId);
1927
+ }
1928
+ throw normalizePortError(error);
1929
+ }
1930
+ try {
1931
+ this.store.db.transaction(() => {
1932
+ const receipt = input.receipt(value);
1933
+ const won = this.store.db.run(
1934
+ "UPDATE side_chat_operations SET state='completed',thread_id=COALESCE(?1,thread_id),turn_id=COALESCE(?2,turn_id),error_code=NULL,updated_at=?3 WHERE operation_id=?4 AND state IN ('ambiguous','reconciling')",
1935
+ [receipt.threadId ?? null, receipt.turnId ?? null, this.now(), input.operationId],
1936
+ );
1937
+ if (won.changes === 1) input.accepted(value);
1938
+ });
1939
+ return value;
1940
+ } catch {
1941
+ this.markAmbiguous(input.sideChatId, input.attemptId, input.operationId, { code: "SIDE_THREAD_LOCAL_APPLY_FAILED" });
1942
+ throw ambiguousError(input.operationId, input.sideChatId, input.attemptId);
1943
+ }
1944
+ }
1945
+
1946
+ private applyLifecycleAccepted(sideChatId: string, action: "archive" | "purge", operationId: string, threadId: string): void {
1947
+ const t = this.now();
1948
+ if (action === "purge") {
1949
+ this.store.db.run("UPDATE side_chats SET state='purged',question_text='',attachments_json='[]',lifecycle_operation=NULL,updated_at=?1,purged_at=?1 WHERE side_chat_id=?2 AND active_attempt_id IS NULL", [t, sideChatId]);
1950
+ this.store.db.run("UPDATE side_chat_attempts SET result_text=NULL,error_text=NULL,attachments_json='[]',updated_at=?1 WHERE side_chat_id=?2", [t, sideChatId]);
1951
+ this.store.db.run("UPDATE side_chat_bring_backs SET error_text=NULL,updated_at=?1 WHERE side_chat_id=?2", [t, sideChatId]);
1952
+ } else {
1953
+ this.store.db.run("UPDATE side_chats SET state='archived',lifecycle_operation=NULL,updated_at=?1,archived_at=?1 WHERE side_chat_id=?2 AND active_attempt_id IS NULL", [t, sideChatId]);
1954
+ }
1955
+ this.emitStored(sideChatId, undefined, threadId, undefined, `side_chat.${action === "archive" ? "archived" : "purged"}`, action === "archive" ? "archived" : "purged", undefined, t);
1956
+ }
1957
+
1726
1958
  listEvents(
1727
1959
  actor: SideThreadActor,
1728
1960
  sideChatId: string,
@@ -1983,14 +2215,19 @@ export class SideThreadCoordinator {
1983
2215
  sideChatId: string,
1984
2216
  attemptId: string,
1985
2217
  error: unknown,
2218
+ operationId?: string,
1986
2219
  ): void {
1987
2220
  const t = this.now(),
1988
2221
  reason = safeReason(
1989
2222
  error instanceof Error ? error.message : String(error),
1990
2223
  );
1991
2224
  this.store.db.transaction(() => {
2225
+ if (operationId)
2226
+ this.settleOperation(operationId, "failed", {
2227
+ errorCode: auditErrorReason(error),
2228
+ });
1992
2229
  const changed = this.store.db.run(
1993
- "UPDATE side_chat_attempts SET state='failed',error_text=?1,updated_at=?2 WHERE attempt_id=?3 AND state='starting'",
2230
+ "UPDATE side_chat_attempts SET state='failed',error_text=?1,updated_at=?2 WHERE attempt_id=?3 AND state IN ('starting','ambiguous','reconciling')",
1994
2231
  [reason, t, attemptId],
1995
2232
  );
1996
2233
  if (changed.changes > 0) {
@@ -2038,7 +2275,7 @@ export class SideThreadCoordinator {
2038
2275
  "SELECT event_id FROM side_chat_events WHERE side_chat_id=?1 ORDER BY event_id DESC LIMIT 1",
2039
2276
  sideChatId,
2040
2277
  );
2041
- this.emitter.emit(`side:${sideChatId}`, {
2278
+ const event = {
2042
2279
  eventId: row?.event_id ?? 0,
2043
2280
  sideChatId,
2044
2281
  attemptId,
@@ -2048,7 +2285,19 @@ export class SideThreadCoordinator {
2048
2285
  state,
2049
2286
  reason: safeReason(reason),
2050
2287
  createdAt: at,
2051
- } satisfies SideThreadEventRecord);
2288
+ } satisfies SideThreadEventRecord;
2289
+ // Fanout is deliberately deferred until the current synchronous SQLite
2290
+ // transaction has committed. Observers are never part of durable truth:
2291
+ // one throwing listener cannot roll back state or starve another listener.
2292
+ queueMicrotask(() => {
2293
+ for (const listener of this.emitter.listeners(`side:${sideChatId}`)) {
2294
+ try {
2295
+ listener(event);
2296
+ } catch {
2297
+ // Durable side_chat_events + cursor replay are authoritative.
2298
+ }
2299
+ }
2300
+ });
2052
2301
  }
2053
2302
  private mustOwned(
2054
2303
  sideChatId: string,
@@ -2239,3 +2488,8 @@ function normalizePortError(error: unknown): SideThreadError {
2239
2488
  502,
2240
2489
  );
2241
2490
  }
2491
+ function persistedOperationFailure(code: string | null | undefined): SideThreadError {
2492
+ if (code === "SIDE_THREAD_UNSUPPORTED")
2493
+ return new SideThreadError("SIDE_THREAD_UNSUPPORTED", "runtime operation is unsupported", 501);
2494
+ return new SideThreadError(code === "SIDE_THREAD_CONFLICT" ? "SIDE_THREAD_CONFLICT" : "SIDE_THREAD_RUNTIME_ERROR", "runtime operation failed authoritatively", code === "SIDE_THREAD_CONFLICT" ? 409 : 502);
2495
+ }
package/src/tools.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { parseDbTimestampMs } from "./db-timestamp.js";
2
3
  import { z } from "zod/v4";
3
4
  import { parseAliasFilter } from "./alias-filter.js";
4
5
  import { createHash } from "node:crypto";
5
6
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
6
- import { getSSEStats, pushEvent, pushNetworkObserverEvent } from "./push.js";
7
+ import { getSSEStats, hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
7
8
  import { assertNodeActive } from "./lifecycle-guard.js";
8
9
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
9
10
  import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
@@ -526,6 +527,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
526
527
  config_revision: z.number().int().min(0).optional(),
527
528
  config_update_capable: z.boolean().optional(),
528
529
  peer_reply_inbox_capable: z.literal(true).optional(),
530
+ side_thread_capability: z.object({
531
+ supported: z.boolean(),
532
+ runtime: z.string().max(64),
533
+ runtimeVersion: z.string().max(64),
534
+ topology: z.string().max(64),
535
+ evidenceRevision: z.string().max(100),
536
+ mode: z.literal("native-exact-fork").optional(),
537
+ exactBoundary: z.object({ through: z.boolean(), before: z.boolean() }).strict().optional(),
538
+ reason: z.enum(["runtime", "version", "topology", "experimental-api", "exact-boundary"]).optional(),
539
+ }).strict().optional(),
529
540
  // RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
530
541
  // discovery (#337 extracts this field). "host_supervisor" =
531
542
  // anet daemon. Default-stripping zod would drop this otherwise.
@@ -1419,12 +1430,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1419
1430
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
1420
1431
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
1421
1432
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
1422
- if (target.state === "online") {
1423
- pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1424
- }
1433
+ // 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
1434
+ // 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
1435
+ // 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
1436
+ // 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
1437
+ // unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
1438
+ // 任务标了 delivered,会话却从未收到推送)。
1439
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1440
+ const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
1425
1441
  // #461 network observer summary — unconditional (task row exists
1426
1442
  // even when the target is offline/queued), metadata only.
1427
- pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: target.state === "online" ? "delivered" : "queued", priority });
1443
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
1428
1444
 
1429
1445
  if (target.state === "offline") {
1430
1446
  return {
@@ -1492,9 +1508,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1492
1508
  [id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
1493
1509
  );
1494
1510
 
1495
- if (target.state === "online") {
1496
- pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1497
- }
1511
+ // 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
1512
+ pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1498
1513
 
1499
1514
  const offlineReply = deliveryTargetReply(target, { message_id: id });
1500
1515
  if (offlineReply) {
@@ -1524,7 +1539,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1524
1539
 
1525
1540
  // ── V2/V3 reply primitives ──
1526
1541
  const replyToolSchema = {
1527
- alias: z.string().min(1).max(200).describe("Target session alias"),
1542
+ // #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
1543
+ // is set, the hub derives the target from the original task's
1544
+ // `from_name` (it already SELECTs that task by in_reply_to below).
1545
+ // The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
1546
+ // send_reply as (task_id, text, status) with NO alias, so before this
1547
+ // it always sent alias=undefined → -32602 "expected string, received
1548
+ // undefined at alias". The target was already knowable from the task,
1549
+ // so requiring the caller to pass it was a redundant-param trap.
1550
+ alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
1528
1551
  text: z.string().min(1).max(10000).describe("Reply content"),
1529
1552
  in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
1530
1553
  status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
@@ -1575,9 +1598,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1575
1598
  : meta;
1576
1599
  const metaJson = normalizeMetaJson(mergedMeta);
1577
1600
 
1578
- const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, alias);
1601
+ // #1085 — derive the reply target when the caller omitted `alias`.
1602
+ // The original task's `from_name` IS the reply target; the hub knows
1603
+ // it from `in_reply_to`. Without this, an SDK-runtime send_reply
1604
+ // (schema has no alias) died with -32602 before reaching this handler.
1605
+ let effectiveAlias: string | undefined =
1606
+ (typeof alias === "string" && alias.trim()) ? alias : undefined;
1607
+ if (!effectiveAlias) {
1608
+ if (!in_reply_to) {
1609
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_required", message: "alias omitted and no in_reply_to to derive it from" }) }] };
1610
+ }
1611
+ const derivParams: any[] = [in_reply_to];
1612
+ let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
1613
+ derivSql = addScope(derivSql, derivParams, effectiveNetId);
1614
+ const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
1615
+ if (!derived?.from_name) {
1616
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_unresolved", message: `alias omitted and could not derive target from in_reply_to (${in_reply_to})` }) }] };
1617
+ }
1618
+ effectiveAlias = derived.from_name;
1619
+ }
1620
+
1621
+ const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
1579
1622
  const replyTargetAlias = canonicalReplyTarget.alias;
1580
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyTargetAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${alias}]` : ""}`);
1623
+ console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyTargetAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${effectiveAlias}]` : ""}`);
1581
1624
  const id = uuidv4();
1582
1625
  const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
1583
1626
  // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
@@ -2767,7 +2810,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2767
2810
  // schema-promoted columns runtimes_supported/allowed_secret_keys
2768
2811
  // are pre-extracted but role isn't a first-class column).
2769
2812
  const nowMs = Date.now();
2770
- const ONLINE_MS = 60_000;
2813
+ // 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
2814
+ // 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
2815
+ // (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
2816
+ const ONLINE_MS = 5 * 60_000;
2771
2817
  const daemons = rows
2772
2818
  .map(r => {
2773
2819
  let snapRole: string | null = null;
@@ -2786,7 +2832,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2786
2832
  let lastSeenAt: string | null = null;
2787
2833
  if (r.session_last_seen) {
2788
2834
  lastSeenAt = r.session_last_seen;
2789
- const t = Date.parse(r.session_last_seen);
2835
+ const t = parseDbTimestampMs(r.session_last_seen);
2790
2836
  if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
2791
2837
  }
2792
2838
  // Parse self-declare arrays (default to [] for pre-PR2 daemons)