@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.
- package/package.json +1 -1
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/delivered-without-push.test.ts +140 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/push.ts +11 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/server.ts +23 -3
- package/src/side-thread-command-transport.test.ts +162 -0
- package/src/side-thread-command-transport.ts +11 -2
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +40 -11
- package/src/side-thread.ts +313 -59
- package/src/tools.ts +59 -13
package/src/side-thread.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 === "
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
operation.
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
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:
|
|
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
|
-
|
|
1496
|
-
|
|
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:
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 =
|
|
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)
|