@agents-ensemble/core 0.13.0 → 0.14.0

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.
Files changed (68) hide show
  1. package/dist/conductor/conductor-send-reconnect.d.ts +3 -0
  2. package/dist/conductor/conductor-send-reconnect.d.ts.map +1 -1
  3. package/dist/conductor/conductor-send-reconnect.js +7 -1
  4. package/dist/conductor/conductor-send-reconnect.js.map +1 -1
  5. package/dist/conductor/conductor-session-driver.d.ts +6 -0
  6. package/dist/conductor/conductor-session-driver.d.ts.map +1 -1
  7. package/dist/conductor/conductor-session-driver.js +17 -1
  8. package/dist/conductor/conductor-session-driver.js.map +1 -1
  9. package/dist/conductor/conductor-session.d.ts +4 -0
  10. package/dist/conductor/conductor-session.d.ts.map +1 -1
  11. package/dist/conductor/conductor-session.js +329 -49
  12. package/dist/conductor/conductor-session.js.map +1 -1
  13. package/dist/conductor/session/session-logger.d.ts +1 -0
  14. package/dist/conductor/session/session-logger.d.ts.map +1 -1
  15. package/dist/conductor/session/session-logger.js +4 -0
  16. package/dist/conductor/session/session-logger.js.map +1 -1
  17. package/dist/conductor/submit-operator-input.d.ts +4 -1
  18. package/dist/conductor/submit-operator-input.d.ts.map +1 -1
  19. package/dist/conductor/submit-operator-input.js +4 -1
  20. package/dist/conductor/submit-operator-input.js.map +1 -1
  21. package/dist/dispatch/report-session-state-tool.d.ts +3 -1
  22. package/dist/dispatch/report-session-state-tool.d.ts.map +1 -1
  23. package/dist/dispatch/report-session-state-tool.js +2 -1
  24. package/dist/dispatch/report-session-state-tool.js.map +1 -1
  25. package/dist/escalation/apply-operator-message.d.ts +2 -0
  26. package/dist/escalation/apply-operator-message.d.ts.map +1 -1
  27. package/dist/escalation/apply-operator-message.js +2 -0
  28. package/dist/escalation/apply-operator-message.js.map +1 -1
  29. package/dist/escalation/open-question.d.ts +1 -0
  30. package/dist/escalation/open-question.d.ts.map +1 -1
  31. package/dist/escalation/open-question.js +1 -1
  32. package/dist/escalation/open-question.js.map +1 -1
  33. package/dist/escalation/record-open-question-answer.d.ts +1 -0
  34. package/dist/escalation/record-open-question-answer.d.ts.map +1 -1
  35. package/dist/escalation/record-open-question-answer.js +1 -0
  36. package/dist/escalation/record-open-question-answer.js.map +1 -1
  37. package/dist/github/register-github-watch-tool.d.ts +4 -1
  38. package/dist/github/register-github-watch-tool.d.ts.map +1 -1
  39. package/dist/github/register-github-watch-tool.js +17 -1
  40. package/dist/github/register-github-watch-tool.js.map +1 -1
  41. package/dist/github/unregister-github-watch-tool.d.ts +4 -1
  42. package/dist/github/unregister-github-watch-tool.d.ts.map +1 -1
  43. package/dist/github/unregister-github-watch-tool.js +14 -3
  44. package/dist/github/unregister-github-watch-tool.js.map +1 -1
  45. package/dist/operator-runtime.d.ts +6 -2
  46. package/dist/operator-runtime.d.ts.map +1 -1
  47. package/dist/operator-runtime.js +4 -1
  48. package/dist/operator-runtime.js.map +1 -1
  49. package/dist/session/index.d.ts +5 -1
  50. package/dist/session/index.d.ts.map +1 -1
  51. package/dist/session/index.js +2 -0
  52. package/dist/session/index.js.map +1 -1
  53. package/dist/session/open-question-control.d.ts +53 -0
  54. package/dist/session/open-question-control.d.ts.map +1 -0
  55. package/dist/session/open-question-control.js +410 -0
  56. package/dist/session/open-question-control.js.map +1 -0
  57. package/dist/session/open-questions-store.d.ts.map +1 -1
  58. package/dist/session/open-questions-store.js +6 -3
  59. package/dist/session/open-questions-store.js.map +1 -1
  60. package/dist/session/session-sidecar.d.ts +5 -0
  61. package/dist/session/session-sidecar.d.ts.map +1 -1
  62. package/dist/session/session-sidecar.js +24 -3
  63. package/dist/session/session-sidecar.js.map +1 -1
  64. package/dist/session/session-state-store.d.ts +21 -0
  65. package/dist/session/session-state-store.d.ts.map +1 -0
  66. package/dist/session/session-state-store.js +131 -0
  67. package/dist/session/session-state-store.js.map +1 -0
  68. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { createAnswerOpenQuestionTool } from '../escalation/answer-open-question
4
4
  import { createAskHumanTool } from '../escalation/ask-human-tool.js';
5
5
  import { createOpenQuestionListTools } from '../escalation/open-question-list-tools.js';
6
6
  import { openQuestionToEscalationRecord } from '../escalation/open-question-to-escalation.js';
7
- import { OpenQuestionRegistry } from '../escalation/open-question.js';
7
+ import { OpenQuestionRegistry, } from '../escalation/open-question.js';
8
8
  import { deny } from '../permission/permission-broker.js';
9
9
  import { PermissionPipeline } from '../permission/permission-pipeline.js';
10
10
  import { createPermissionDeadlockMonitor, } from '../permission/permission-deadlock-monitor.js';
@@ -37,8 +37,10 @@ import { isOperatorExitCommand } from './operator-exit.js';
37
37
  import { isOperatorCloseCommand } from './operator-close.js';
38
38
  import { isOperatorReconnectCommand } from './operator-reconnect.js';
39
39
  import { assertSessionSidecarMatches, requireSessionSidecarForResume, saveSessionSidecar, SESSION_SIDECAR_VERSION, sessionSidecarPath, } from '../session/session-sidecar.js';
40
+ import { saveSessionStatusLine, sessionStateStorePath, } from '../session/session-state-store.js';
40
41
  import { loadToolCallSnapshotHistory, pruneExpiredToolCallSnapshotHistoryFiles, saveToolCallSnapshotHistory, toolCallSnapshotHistoryPath, } from '../session/tool-call-snapshot-history.js';
41
42
  import { loadOpenQuestionsStore, openQuestionsStorePath, saveOpenQuestionsStore, } from '../session/open-questions-store.js';
43
+ import { OpenQuestionControlOperationError, startOpenQuestionControlServer, } from '../session/open-question-control.js';
42
44
  import { formatPermissionSummaryForOperator } from '../permission/format-permission-summary-for-operator.js';
43
45
  import { enrichSessionUsageWithCost } from '../usage/enrich-session-usage-cost.js';
44
46
  import { SessionUsageTracker } from '../usage/session-usage-tracker.js';
@@ -54,6 +56,12 @@ import { createRegisterGitHubWatchTool } from '../github/register-github-watch-t
54
56
  import { createUnregisterGitHubWatchTool } from '../github/unregister-github-watch-tool.js';
55
57
  import { compileConductorSystemPrompt } from '../prompt/compile-system-prompt.js';
56
58
  import { loadUserInstructions } from '../prompt/load-user-instructions.js';
59
+ const OPEN_QUESTION_CONTROL_DRAIN_TIMEOUT_MS = 5_000;
60
+ const RECORDED_ANSWER_UNKNOWN_DISPATCH = {
61
+ outcome: 'unknown',
62
+ answerStatus: 'recorded',
63
+ dispatchStatus: 'unknown',
64
+ };
57
65
  export async function runConductorSession(options) {
58
66
  const userInstructions = await loadUserInstructions({
59
67
  userEnsembleRoot: options.mcpConfigOptions?.userEnsembleRoot,
@@ -79,7 +87,11 @@ export async function runConductorSession(options) {
79
87
  }
80
88
  const escalations = [];
81
89
  const openQuestions = new OpenQuestionRegistry();
90
+ let harnessSocketPath;
91
+ let openQuestionControlServer;
82
92
  const eventQueue = new SessionEventQueue();
93
+ const openQuestionDispatchReceipts = new WeakMap();
94
+ const pendingOpenQuestionDispatchReceipts = new Set();
83
95
  const dispatchHoldState = createDispatchHoldState();
84
96
  const onDispatchHoldChanged = (change) => {
85
97
  sessionLogger.emit({
@@ -105,11 +117,11 @@ export async function runConductorSession(options) {
105
117
  let resumeToolCallSnapshots = [];
106
118
  let toolCallHistoryNeedsRewrite = false;
107
119
  if (options.resumeAgentId) {
108
- const sidecar = await requireSessionSidecarForResume({
120
+ const loadedSidecar = await requireSessionSidecarForResume({
109
121
  repoRoot: options.repoRoot,
110
122
  conductorAgentId: options.resumeAgentId,
111
123
  });
112
- assertSessionSidecarMatches(sidecar, {
124
+ assertSessionSidecarMatches(loadedSidecar, {
113
125
  conductorAgentId: options.resumeAgentId,
114
126
  issueUrl: options.issueUrl,
115
127
  repoRoot: options.repoRoot,
@@ -117,6 +129,20 @@ export async function runConductorSession(options) {
117
129
  mcpConfigDigest,
118
130
  mcpConfigConfigured,
119
131
  });
132
+ // A previously completed lifecycle no longer describes this instance once
133
+ // resume begins. Persist unknown before restoring or attaching anything so
134
+ // a crash before the first regular sidecar flush cannot leave stale
135
+ // `closed` / `exited` data behind.
136
+ const sidecar = {
137
+ ...loadedSidecar,
138
+ lifecycle: 'unknown',
139
+ harnessSocketPath: undefined,
140
+ updatedAt: Date.now(),
141
+ };
142
+ await saveSessionSidecar(sessionSidecarPath({
143
+ repoRoot: options.repoRoot,
144
+ conductorAgentId: options.resumeAgentId,
145
+ }), sidecar);
120
146
  const history = await loadToolCallSnapshotHistory({
121
147
  repoRoot: options.repoRoot,
122
148
  conductorAgentId: options.resumeAgentId,
@@ -195,12 +221,62 @@ export async function runConductorSession(options) {
195
221
  ? new AbortController()
196
222
  : undefined;
197
223
  const shutdownSignal = options.shutdownSignal ?? shutdownController.signal;
224
+ const shutdownIntentController = new AbortController();
198
225
  const operatorTerminationController = new AbortController();
226
+ const drainedShutdownController = new AbortController();
199
227
  const driverShutdownSignal = AbortSignal.any([
200
- shutdownSignal,
228
+ drainedShutdownController.signal,
201
229
  operatorTerminationController.signal,
202
230
  ]);
231
+ const abortAfterOpenQuestionDrain = (controller) => {
232
+ const activeControlServer = openQuestionControlServer;
233
+ if (!activeControlServer) {
234
+ controller.abort();
235
+ return;
236
+ }
237
+ const reportDrainFailure = (error) => {
238
+ const detail = error instanceof Error ? error.message : String(error);
239
+ sessionLogger.emit({
240
+ type: 'harness.warning',
241
+ message: `Open Question control drain failed: ${detail}`,
242
+ });
243
+ };
244
+ const quiescing = activeControlServer.quiesce();
245
+ if (!activeControlServer.hasInFlightRequests()) {
246
+ void quiescing.catch(reportDrainFailure);
247
+ controller.abort();
248
+ return;
249
+ }
250
+ let drainTimeout;
251
+ const drainDeadline = new Promise((resolve) => {
252
+ drainTimeout = setTimeout(() => resolve('timeout'), OPEN_QUESTION_CONTROL_DRAIN_TIMEOUT_MS);
253
+ });
254
+ const drainResult = quiescing.then(() => 'drained', (error) => {
255
+ reportDrainFailure(error);
256
+ return 'failed';
257
+ });
258
+ void Promise.race([drainResult, drainDeadline]).then((result) => {
259
+ if (drainTimeout)
260
+ clearTimeout(drainTimeout);
261
+ if (result === 'timeout') {
262
+ sessionLogger.emit({
263
+ type: 'harness.warning',
264
+ message: `Open Question control drain exceeded ${OPEN_QUESTION_CONTROL_DRAIN_TIMEOUT_MS}ms; aborting conductor dispatch. Accepted answers may have unknown notification outcomes.`,
265
+ });
266
+ }
267
+ controller.abort();
268
+ });
269
+ };
270
+ const onShutdownSignal = () => {
271
+ shutdownIntentController.abort();
272
+ abortAfterOpenQuestionDrain(drainedShutdownController);
273
+ };
274
+ shutdownSignal.addEventListener('abort', onShutdownSignal, { once: true });
275
+ if (shutdownSignal.aborted)
276
+ onShutdownSignal();
203
277
  let unregisterProcessSignalHandlers = () => { };
278
+ const removeShutdownDrainListener = () => shutdownSignal.removeEventListener('abort', onShutdownSignal);
279
+ unregisterProcessSignalHandlers = removeShutdownDrainListener;
204
280
  if (ownsShutdownController &&
205
281
  options.registerProcessSignalHandlers !== false) {
206
282
  const onSignal = () => shutdownController.abort();
@@ -209,11 +285,13 @@ export async function runConductorSession(options) {
209
285
  unregisterProcessSignalHandlers = () => {
210
286
  process.off('SIGINT', onSignal);
211
287
  process.off('SIGTERM', onSignal);
288
+ removeShutdownDrainListener();
212
289
  };
213
290
  }
214
291
  const permissionPipeline = options.permissionPipeline ??
215
292
  new PermissionPipeline({ policy: options.permissionPolicy });
216
293
  let scheduleSidecarFlush = () => { };
294
+ let flushSidecar = async () => { };
217
295
  let scheduleToolCallHistoryFlush = () => { };
218
296
  let flushToolCallHistory = async () => { };
219
297
  let toolCallHistoryDirty = toolCallHistoryNeedsRewrite;
@@ -535,9 +613,12 @@ export async function runConductorSession(options) {
535
613
  githubMonitorCursor ??= emptyGitHubMonitorCursor();
536
614
  return githubMonitorCursor;
537
615
  },
538
- onRegistered: (registration) => {
616
+ onRegistered: async (registration) => {
539
617
  githubMonitor?.registerPullRequest?.(registration);
540
- scheduleSidecarFlush();
618
+ // Do not report success to the conductor until the explicit watch is
619
+ // durable. This closes the crash window between the tool result and a
620
+ // later best-effort flush.
621
+ await flushSidecar();
541
622
  },
542
623
  });
543
624
  const unregisterGitHubWatchTools = createUnregisterGitHubWatchTool({
@@ -546,9 +627,9 @@ export async function runConductorSession(options) {
546
627
  githubMonitorCursor ??= emptyGitHubMonitorCursor();
547
628
  return githubMonitorCursor;
548
629
  },
549
- onUnregistered: (prNumber) => {
630
+ onUnregistered: async (prNumber) => {
550
631
  githubMonitor?.unregisterPullRequest?.(prNumber);
551
- scheduleSidecarFlush();
632
+ await flushSidecar();
552
633
  },
553
634
  });
554
635
  let conductorAgent;
@@ -560,7 +641,20 @@ export async function runConductorSession(options) {
560
641
  return usage.cost;
561
642
  },
562
643
  });
563
- const reportSessionStateTool = createReportSessionStateTool();
644
+ let sessionStateWriteQueue = Promise.resolve();
645
+ const reportSessionStateTool = createReportSessionStateTool({
646
+ onReport: (statusLine) => {
647
+ if (teardownStarted) {
648
+ throw new Error('report_session_state is unavailable during teardown');
649
+ }
650
+ const write = sessionStateWriteQueue.then(() => saveSessionStatusLine(sessionStateStorePath({
651
+ repoRoot: options.repoRoot,
652
+ conductorAgentId: conductorAgent.agentId,
653
+ }), statusLine));
654
+ sessionStateWriteQueue = write.catch(() => { });
655
+ return write;
656
+ },
657
+ });
564
658
  const dispatchHoldTools = createSetDispatchHoldTool({
565
659
  state: dispatchHoldState,
566
660
  onChanged: onDispatchHoldChanged,
@@ -632,6 +726,7 @@ export async function runConductorSession(options) {
632
726
  const sendReconnect = {
633
727
  conductorAgentFactory,
634
728
  conductorOptions,
729
+ retrySuppressionSignal: shutdownIntentController.signal,
635
730
  onAuthReconnectAttempt: ({ agentId }) => {
636
731
  sessionLogger.emit({
637
732
  type: 'conductor.auth.reconnect',
@@ -655,46 +750,59 @@ export async function runConductorSession(options) {
655
750
  },
656
751
  };
657
752
  let sidecarFlushQueue = Promise.resolve();
658
- let flushSidecar = async () => { };
659
- flushSidecar = () => {
660
- const nextFlush = sidecarFlushQueue.then(async () => {
661
- const workers = {};
662
- for (const [name, worker] of workerSessions) {
663
- workers[name] = {
664
- acpSessionId: worker.acpSessionId,
665
- ...(worker.acpCwd ? { acpCwd: worker.acpCwd } : {}),
666
- ...(worker.acpSpawn ? { acpSpawn: worker.acpSpawn } : {}),
667
- };
668
- }
669
- const snapshot = openQuestions.snapshot();
670
- const sidecar = {
671
- version: SESSION_SIDECAR_VERSION,
672
- conductorAgentId: conductorHandle.conductor.agentId,
673
- conductorBackend,
674
- lazyWorkers: activeLazyWorkers,
675
- issueUrl: options.issueUrl,
676
- repoRoot: options.repoRoot,
677
- profile: structuredClone(activeProfile),
678
- ...(options.profilePath ? { profilePath: options.profilePath } : {}),
679
- openQuestions: snapshot.openQuestions,
680
- sequence: snapshot.sequence,
681
- workers,
682
- mcpConfigDigest,
683
- ...(githubMonitorCursor ? { githubMonitor: githubMonitorCursor } : {}),
684
- updatedAt: Date.now(),
753
+ let terminalLifecycle;
754
+ const enqueueSidecarWrite = (write) => {
755
+ const nextWrite = sidecarFlushQueue.then(write);
756
+ sidecarFlushQueue = nextWrite.catch(() => { });
757
+ return nextWrite;
758
+ };
759
+ const createSessionSidecar = (snapshot, lifecycle) => {
760
+ const workers = {};
761
+ for (const [name, worker] of workerSessions) {
762
+ workers[name] = {
763
+ acpSessionId: worker.acpSessionId,
764
+ ...(worker.acpCwd ? { acpCwd: worker.acpCwd } : {}),
765
+ ...(worker.acpSpawn ? { acpSpawn: worker.acpSpawn } : {}),
685
766
  };
686
- await saveOpenQuestionsStore(openQuestionsStorePath({
687
- repoRoot: options.repoRoot,
688
- conductorAgentId: conductorHandle.conductor.agentId,
689
- }), snapshot);
690
- await saveSessionSidecar(sessionSidecarPath({
691
- repoRoot: options.repoRoot,
692
- conductorAgentId: conductorHandle.conductor.agentId,
693
- }), sidecar);
694
- });
695
- sidecarFlushQueue = nextFlush.catch(() => { });
696
- return nextFlush;
767
+ }
768
+ return {
769
+ version: SESSION_SIDECAR_VERSION,
770
+ conductorAgentId: conductorHandle.conductor.agentId,
771
+ conductorBackend,
772
+ lazyWorkers: activeLazyWorkers,
773
+ ...(harnessSocketPath ? { harnessSocketPath } : {}),
774
+ issueUrl: options.issueUrl,
775
+ repoRoot: options.repoRoot,
776
+ profile: structuredClone(activeProfile),
777
+ ...(options.profilePath ? { profilePath: options.profilePath } : {}),
778
+ openQuestions: snapshot.openQuestions,
779
+ sequence: snapshot.sequence,
780
+ workers,
781
+ mcpConfigDigest,
782
+ ...((terminalLifecycle ?? lifecycle)
783
+ ? { lifecycle: terminalLifecycle ?? lifecycle }
784
+ : {}),
785
+ ...(githubMonitorCursor ? { githubMonitor: githubMonitorCursor } : {}),
786
+ updatedAt: Date.now(),
787
+ };
788
+ };
789
+ const persistOpenQuestionsSnapshot = async (snapshot) => {
790
+ await saveOpenQuestionsStore(openQuestionsStorePath({
791
+ repoRoot: options.repoRoot,
792
+ conductorAgentId: conductorHandle.conductor.agentId,
793
+ }), snapshot);
697
794
  };
795
+ const persistSessionSidecar = async (snapshot, lifecycle) => {
796
+ await saveSessionSidecar(sessionSidecarPath({
797
+ repoRoot: options.repoRoot,
798
+ conductorAgentId: conductorHandle.conductor.agentId,
799
+ }), createSessionSidecar(snapshot, lifecycle));
800
+ };
801
+ flushSidecar = (lifecycle) => enqueueSidecarWrite(async () => {
802
+ const snapshot = openQuestions.snapshot();
803
+ await persistOpenQuestionsSnapshot(snapshot);
804
+ await persistSessionSidecar(snapshot, lifecycle);
805
+ });
698
806
  const reportSidecarFlushFailure = (error) => {
699
807
  const detail = error instanceof Error ? error.message : String(error);
700
808
  sessionLogger.emit({
@@ -702,6 +810,13 @@ export async function runConductorSession(options) {
702
810
  message: `Open Question YAML / session sidecar を保存できませんでした。最新状態から再開できず、直前に保存された状態からの再開になる場合があります。 error=${detail}`,
703
811
  });
704
812
  };
813
+ const reportSessionSidecarWriteFailure = (error) => {
814
+ const detail = error instanceof Error ? error.message : String(error);
815
+ sessionLogger.emit({
816
+ type: 'harness.warning',
817
+ message: `Session sidecar の更新に失敗しました。Open Question YAML の保存は完了しており、sidecar は後続 flush で同期します。 error=${detail}`,
818
+ });
819
+ };
705
820
  scheduleSidecarFlush = () => {
706
821
  void flushSidecar().catch(reportSidecarFlushFailure);
707
822
  };
@@ -878,14 +993,16 @@ export async function runConductorSession(options) {
878
993
  sessionLogger.emit({ type: 'session.operator_exit' });
879
994
  workerSession.runtime.cancelAllActivePrompts();
880
995
  operatorRequestedExit = true;
881
- operatorTerminationController.abort();
996
+ shutdownIntentController.abort();
997
+ abortAfterOpenQuestionDrain(operatorTerminationController);
882
998
  return false;
883
999
  }
884
1000
  if (isOperatorCloseCommand(message)) {
885
1001
  sessionLogger.emit({ type: 'session.operator_close' });
886
1002
  workerSession.runtime.cancelAllActivePrompts();
887
1003
  operatorRequestedClose = true;
888
- operatorTerminationController.abort();
1004
+ shutdownIntentController.abort();
1005
+ abortAfterOpenQuestionDrain(operatorTerminationController);
889
1006
  return false;
890
1007
  }
891
1008
  if (isOperatorReconnectCommand(message)) {
@@ -919,6 +1036,114 @@ export async function runConductorSession(options) {
919
1036
  }
920
1037
  }
921
1038
  try {
1039
+ openQuestionControlServer = await startOpenQuestionControlServer({
1040
+ listOpenQuestions: () => openQuestions.listOpen(),
1041
+ answerOpenQuestion: async ({ questionId, answer }) => {
1042
+ if (teardownStarted) {
1043
+ throw new OpenQuestionControlOperationError(-32000, 'The session is shutting down.');
1044
+ }
1045
+ const current = openQuestions.get(questionId);
1046
+ if (!current || current.status !== 'open') {
1047
+ throw new OpenQuestionControlOperationError(-32004, 'Open Question is not available.');
1048
+ }
1049
+ let accepted = false;
1050
+ let sidecarWriteError;
1051
+ let dispatchReceipt;
1052
+ let answered;
1053
+ const escalatedRecords = [];
1054
+ await enqueueSidecarWrite(async () => {
1055
+ const stagedOpenQuestions = new OpenQuestionRegistry();
1056
+ stagedOpenQuestions.restore(openQuestions.snapshot());
1057
+ const stagedAccepted = submitOperatorInput({
1058
+ message: answer,
1059
+ targetOpenQuestionId: questionId,
1060
+ conductorTurn: sendCount + 1,
1061
+ openQuestions: stagedOpenQuestions,
1062
+ escalations: [],
1063
+ eventQueue: new SessionEventQueue(),
1064
+ sessionLogger: { emit: () => { } },
1065
+ });
1066
+ const stagedAnswer = stagedOpenQuestions.get(questionId);
1067
+ if (!stagedAccepted ||
1068
+ !stagedAnswer ||
1069
+ stagedAnswer.status !== 'answered') {
1070
+ return;
1071
+ }
1072
+ // The Open Questions YAML is the durable commit point. Do not expose
1073
+ // the answer in the live Registry or event queue until this succeeds.
1074
+ await persistOpenQuestionsSnapshot(stagedOpenQuestions.snapshot());
1075
+ // Another answer path may have won while the atomic YAML write was
1076
+ // in flight. Restore the current Registry snapshot before rejecting.
1077
+ if (openQuestions.get(questionId)?.status !== 'open') {
1078
+ await persistOpenQuestionsSnapshot(openQuestions.snapshot());
1079
+ return;
1080
+ }
1081
+ accepted = submitOperatorInput({
1082
+ message: answer,
1083
+ targetOpenQuestionId: questionId,
1084
+ answeredAt: stagedAnswer.answeredAt,
1085
+ conductorTurn: sendCount + 1,
1086
+ openQuestions,
1087
+ escalations,
1088
+ eventQueue,
1089
+ sessionLogger,
1090
+ onEscalated: (record) => escalatedRecords.push(record),
1091
+ onOperatorMessageQueued: (event) => {
1092
+ let receipt;
1093
+ dispatchReceipt = new Promise((resolve, reject) => {
1094
+ receipt = {
1095
+ resolve: () => resolve(),
1096
+ reject: (error) => reject(error),
1097
+ };
1098
+ openQuestionDispatchReceipts.set(event, receipt);
1099
+ pendingOpenQuestionDispatchReceipts.add(receipt);
1100
+ });
1101
+ void dispatchReceipt.catch(() => { });
1102
+ },
1103
+ });
1104
+ answered = openQuestions.get(questionId);
1105
+ if (!accepted || !answered || answered.status !== 'answered') {
1106
+ await persistOpenQuestionsSnapshot(openQuestions.snapshot());
1107
+ accepted = false;
1108
+ return;
1109
+ }
1110
+ try {
1111
+ await persistSessionSidecar(openQuestions.snapshot());
1112
+ }
1113
+ catch (error) {
1114
+ // YAML is authoritative and is already durable. Keep the accepted
1115
+ // answer/notification, report the mirror failure, then repair it.
1116
+ sidecarWriteError = error;
1117
+ }
1118
+ });
1119
+ if (!accepted || !answered || answered.status !== 'answered') {
1120
+ throw new OpenQuestionControlOperationError(-32004, 'Open Question could not be answered.');
1121
+ }
1122
+ for (const record of escalatedRecords) {
1123
+ try {
1124
+ options.onEscalated?.(record);
1125
+ }
1126
+ catch (error) {
1127
+ const detail = error instanceof Error ? error.message : String(error);
1128
+ sessionLogger.emit({
1129
+ type: 'harness.warning',
1130
+ message: `Open Question observer callback failed: ${detail}`,
1131
+ });
1132
+ }
1133
+ }
1134
+ if (sidecarWriteError !== undefined) {
1135
+ reportSessionSidecarWriteFailure(sidecarWriteError);
1136
+ scheduleSidecarFlush();
1137
+ }
1138
+ if (!dispatchReceipt) {
1139
+ throw new OpenQuestionControlOperationError(-32603, 'The answer was recorded, but its conductor notification was not queued. The outcome is unknown; do not resubmit the answer and inspect the session state.', RECORDED_ANSWER_UNKNOWN_DISPATCH);
1140
+ }
1141
+ await dispatchReceipt;
1142
+ return answered;
1143
+ },
1144
+ });
1145
+ harnessSocketPath = openQuestionControlServer.socketPath;
1146
+ await flushSidecar().catch(reportSidecarFlushFailure);
922
1147
  const driverResult = await runConductorSessionDriver({
923
1148
  issueUrl: options.issueUrl,
924
1149
  initialPrompt: conductorBackend === 'pi'
@@ -979,6 +1204,23 @@ export async function runConductorSession(options) {
979
1204
  });
980
1205
  },
981
1206
  onSendComplete: recordSendComplete,
1207
+ onDispatchBatchSettled: ({ events, result, error }) => {
1208
+ for (const event of events) {
1209
+ if (event.type !== 'operator.message')
1210
+ continue;
1211
+ const receipt = openQuestionDispatchReceipts.get(event);
1212
+ if (!receipt)
1213
+ continue;
1214
+ openQuestionDispatchReceipts.delete(event);
1215
+ pendingOpenQuestionDispatchReceipts.delete(receipt);
1216
+ if (error || result?.status !== 'finished') {
1217
+ receipt.reject(new OpenQuestionControlOperationError(-32005, 'The answer was recorded and persisted, but conductor notification could not be confirmed. The outcome is unknown; do not resubmit the answer and inspect the session state.', RECORDED_ANSWER_UNKNOWN_DISPATCH));
1218
+ }
1219
+ else {
1220
+ receipt.resolve();
1221
+ }
1222
+ }
1223
+ },
982
1224
  onOpenQuestionEnqueued: (question) => {
983
1225
  sessionLogger.emit({ type: 'open.question.enqueued', question });
984
1226
  options.onOpenQuestionEnqueued?.(question);
@@ -1069,7 +1311,19 @@ export async function runConductorSession(options) {
1069
1311
  }
1070
1312
  }
1071
1313
  finally {
1314
+ const lifecycle = operatorRequestedClose
1315
+ ? 'closed'
1316
+ : operatorRequestedExit
1317
+ ? 'exited'
1318
+ : shutdownSignal.aborted
1319
+ ? 'interrupted'
1320
+ : 'unknown';
1321
+ terminalLifecycle = lifecycle;
1072
1322
  teardownStarted = true;
1323
+ for (const receipt of pendingOpenQuestionDispatchReceipts) {
1324
+ receipt.reject(new OpenQuestionControlOperationError(-32005, 'The answer was recorded and persisted, but the conductor session stopped before notification completed. The outcome is unknown; do not resubmit the answer and inspect the session state.', RECORDED_ANSWER_UNKNOWN_DISPATCH));
1325
+ }
1326
+ pendingOpenQuestionDispatchReceipts.clear();
1073
1327
  const forceShutdown = operatorRequestedExit ||
1074
1328
  operatorRequestedClose ||
1075
1329
  shutdownSignal.aborted;
@@ -1095,6 +1349,24 @@ export async function runConductorSession(options) {
1095
1349
  sessionLogger.emit({ type: 'harness.teardown.phase', phase });
1096
1350
  };
1097
1351
  try {
1352
+ const activeControlServer = openQuestionControlServer;
1353
+ openQuestionControlServer = undefined;
1354
+ harnessSocketPath = undefined;
1355
+ if (activeControlServer) {
1356
+ emitTeardownPhase('openQuestionControl');
1357
+ const phaseStart = Date.now();
1358
+ try {
1359
+ await activeControlServer.close();
1360
+ }
1361
+ catch (error) {
1362
+ const detail = error instanceof Error ? error.message : String(error);
1363
+ sessionLogger.emit({
1364
+ type: 'harness.warning',
1365
+ message: `Open Question control socket cleanup failed: ${detail}`,
1366
+ });
1367
+ }
1368
+ teardownPhases.openQuestionControl = Date.now() - phaseStart;
1369
+ }
1098
1370
  disposeOperatorInput?.();
1099
1371
  if (permissionDeadlockMonitor) {
1100
1372
  emitTeardownPhase('permissionDeadlockMonitor');
@@ -1212,6 +1484,14 @@ export async function runConductorSession(options) {
1212
1484
  }
1213
1485
  }
1214
1486
  finally {
1487
+ try {
1488
+ emitTeardownPhase('flushSidecar');
1489
+ await sessionStateWriteQueue;
1490
+ await flushSidecar(lifecycle);
1491
+ }
1492
+ catch (error) {
1493
+ reportSidecarFlushFailure(error);
1494
+ }
1215
1495
  process.off('SIGINT', onTeardownSignal);
1216
1496
  process.off('SIGTERM', onTeardownSignal);
1217
1497
  }