@engineeros/connector 0.15.3 → 0.16.1

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/README.md CHANGED
@@ -47,7 +47,7 @@ npx --yes @engineeros/connector@latest pair PAIRING-CODE --url https://your-engi
47
47
 
48
48
  The connector uploads a bounded ZIP snapshot for a safe file inventory, then stays online for deep assessments, rescans, and Goal Runs. Inventory never executes repository code. It excludes known secrets, dependency directories, build output, compiled binaries, files larger than 5 MB, agent-tool caches, Git metadata, and connector state before upload.
49
49
 
50
- From **Project steering -> Workspace**, run the workspace assessment to use the connected agent subscription already authenticated on that computer. Select any combination of fifteen brownfield assessment domains, collectively covering 133 baseline checks from product and architecture through security, privacy, project-specific compliance, delivery, reliability, governance, and modernization planning. Every domain uses a detailed structured-Markdown contract. Connector `0.15.3` starts assessments with three parallel workers and accepts the run's UI-selected limit from three through eight for every coding-agent provider. It atomically spools each completed Markdown report under `.engineeros/assessments/<run-id>` before delivery. ACP agents such as OpenCode use isolated stage sessions within one persistent coding-agent process. The connector owns the worker pool and sends one aggregate heartbeat containing every active worker's phase, progress, last activity, and event count; report content is no longer streamed over the WebSocket or written into backend assessment JSON. The backend validates stage checkpoints so dependency fan-out remains authoritative, while the connector retains the reports and reuses them after reconnect without rerunning completed agents. Capability detail and project-specific compliance controls fan out after their catalogs. Final synthesis reads the connector-local Markdown files, then one completed bundle is revalidated, persisted, and published as canonical artifacts. The connector removes its spool only after EngineerOS acknowledges the complete bundle. Compliance findings describe repository-verifiable engineering readiness and missing external context, never legal compliance or certification. The connector prints each worker's safe activity and elapsed time every 30 seconds, stops a worker that produces no agent activity for ten minutes instead of waiting indefinitely, and applies separate bounded structure, semantic, and corrected-response formatting repairs before failing a stage. Assessment cannot modify tracked workspace source.
50
+ From **Project steering -> Workspace**, run the workspace assessment to use the connected agent subscription already authenticated on that computer. Select any combination of fifteen brownfield assessment domains, collectively covering 133 internal checks from product and architecture through security, privacy, project-specific compliance, delivery, reliability, governance, and modernization planning. Reports include only material conclusions and actions; routine healthy checklist rows are not published. Capability entries state what they own, what they link to, and what they depend on. Connector `0.16.1` starts assessments with three parallel workers and accepts the run's UI-selected limit from three through eight for every coding-agent provider. It keeps one local assessment area at `.engineeros/assessment`: the initial accepted run creates `assessment.md`, later code revisions create one consolidated file under `changes/`, and in-progress reports plus resumable agent-session checkpoints stay under `.work/` until final acceptance. ACP agents such as OpenCode use isolated stage sessions within one persistent coding-agent process. The connector owns the worker pool and sends one aggregate heartbeat containing every active worker's phase, progress, last activity, and event count; report content is no longer streamed over the WebSocket or written into backend assessment JSON. The backend validates stage checkpoints so dependency fan-out remains authoritative, while the connector retains completed reports and reloads interrupted ACP or Codex sessions after reconnect when the coding agent supports session restoration. Transient ACP, app-server, provider-availability, and rate-limit failures receive two bounded attempts to continue the saved session without repeating completed inspection. A changed Git commit updates the same durable assessment, retains the prior commit in assessment history, and updates published artifacts in place. Connector reports and session checkpoints are tagged with their target commit so an earlier revision cannot leak into the update. If no assessment exists, EngineerOS creates the initial assessment identity. Capability detail and project-specific compliance controls fan out after their catalogs. Final synthesis reads the connector-local Markdown files, then one completed bundle is revalidated, persisted, published as canonical artifacts, and archived locally. Compliance findings describe repository-verifiable engineering readiness and missing external context, never legal compliance or certification. The connector prints each worker's safe activity and elapsed time every 30 seconds, stops a worker that produces no agent activity for ten minutes instead of waiting indefinitely, and applies separate bounded structure, semantic, and corrected-response formatting repairs before failing a stage. Assessment cannot modify tracked workspace source.
51
51
 
52
52
  Every backend `422` content-validation response is returned to the stage agent with the exact failed item. Distinct validation failures continue through correction and resubmission until the complete stage is accepted. Three repetitions of the same unresolved validation error stop the automatic loop while preserving the latest local report for retry. Authentication, cancellation, source drift, and transport failures remain operational errors rather than agent-correction prompts.
53
53
 
@@ -21,10 +21,11 @@ import {
21
21
  applyAcceptedChange,
22
22
  enqueueWorkspaceAssessment,
23
23
  executeAssignment,
24
- executeConnectedPrompt,
25
- executeWorkspaceAssessment,
26
- inspectCodingAgent,
27
- promptStreamEvent,
24
+ executeConnectedPrompt,
25
+ executeWorkspaceAssessment,
26
+ inspectCodingAgent,
27
+ isRecoverableAssessmentAgentFailure,
28
+ promptStreamEvent,
28
29
  requeueInterruptedAssessment,
29
30
  stopProcess,
30
31
  submitAssessmentResultWithRetry,
@@ -33,12 +34,15 @@ import {
33
34
  workspaceAssessmentWorkerLimit,
34
35
  workspaceSnapshot,
35
36
  } from "../src/runner.mjs";
36
- import {
37
- assessmentCheckpointPayload,
38
- assessmentCompletionBundle,
39
- clearAssessmentSpool,
40
- loadAssessmentStageResult,
41
- persistAssessmentStageResult,
37
+ import {
38
+ archiveCompletedAssessment,
39
+ assessmentCheckpointPayload,
40
+ assessmentCompletionBundle,
41
+ assessmentRunDirectory,
42
+ loadAssessmentStageSession,
43
+ loadAssessmentStageResult,
44
+ persistAssessmentStageResult,
45
+ persistAssessmentStageSession,
42
46
  prepareLocalAssessmentAssignment,
43
47
  removeAssessmentStageResult,
44
48
  } from "../src/assessment-spool.mjs";
@@ -50,11 +54,12 @@ import {
50
54
  registeredAgents,
51
55
  } from "../src/agent-registry.mjs";
52
56
  import {
53
- describeRejectedResponse,
54
- describeWebSocketError,
55
- protocolFailureMessage,
56
- startConnectionWatchdog,
57
- } from "../src/connection.mjs";
57
+ describeRejectedResponse,
58
+ describeWebSocketError,
59
+ protocolFailureMessage,
60
+ sendConnectionMessage,
61
+ startConnectionWatchdog,
62
+ } from "../src/connection.mjs";
58
63
  import { advertisedCapabilities } from "../src/capabilities.mjs";
59
64
  import { parseConnectorArgs } from "../src/cli-args.mjs";
60
65
  import { runMcpServer } from "../src/mcp-server.mjs";
@@ -250,25 +255,47 @@ process.on("SIGINT", async () => {
250
255
 
251
256
  await connect();
252
257
 
253
- async function connect() {
254
- console.log(`Connecting ${config.name} to ${config.server_url}`);
255
- connectionRejected = false;
256
- lastConnectionError = undefined;
257
- socket = new WebSocket(config.server_url);
258
- clearConnectionWatchdog = startConnectionWatchdog(socket, config.server_url, {
259
- onTimeout: (message) => {
260
- lastConnectionError = message;
261
- console.error(message);
262
- scheduleReconnect();
263
- },
264
- });
265
- socket.addEventListener("open", () =>
266
- socket.send(JSON.stringify(firstMessage)),
267
- );
268
- socket.addEventListener("message", async (event) => {
269
- const message = JSON.parse(String(event.data));
270
- if (message.type === "paired") {
271
- clearConnectionWatchdog?.();
258
+ async function connect() {
259
+ console.log(`Connecting ${config.name} to ${config.server_url}`);
260
+ connectionRejected = false;
261
+ lastConnectionError = undefined;
262
+ const connection = new WebSocket(config.server_url);
263
+ socket = connection;
264
+ const clearWatchdog = startConnectionWatchdog(connection, config.server_url, {
265
+ onTimeout: (message) => {
266
+ if (socket !== connection) return;
267
+ lastConnectionError = message;
268
+ console.error(message);
269
+ scheduleReconnect();
270
+ },
271
+ });
272
+ clearConnectionWatchdog = clearWatchdog;
273
+ connection.addEventListener("open", () => {
274
+ if (socket !== connection) {
275
+ connection.close();
276
+ return;
277
+ }
278
+ try {
279
+ if (sendConnectionMessage(socket, connection, firstMessage)) return;
280
+ throw new Error("The WebSocket was not open when authentication started.");
281
+ } catch (error) {
282
+ lastConnectionError = protocolFailureMessage(error);
283
+ console.error(
284
+ `Could not authenticate the WebSocket connection: ${lastConnectionError}`,
285
+ );
286
+ try {
287
+ connection.close();
288
+ } catch {
289
+ // The reconnect timer below owns recovery.
290
+ }
291
+ scheduleReconnect();
292
+ }
293
+ });
294
+ connection.addEventListener("message", async (event) => {
295
+ if (socket !== connection) return;
296
+ const message = JSON.parse(String(event.data));
297
+ if (message.type === "paired") {
298
+ clearWatchdog();
272
299
  const reusedConnector =
273
300
  existingConfig?.connector_id === message.connector.id;
274
301
  config = mergePairedConfig(
@@ -294,8 +321,8 @@ async function connect() {
294
321
  if (config.onboarding_pending) void submitWorkspaceSnapshot();
295
322
  return;
296
323
  }
297
- if (message.type === "authenticated") {
298
- clearConnectionWatchdog?.();
324
+ if (message.type === "authenticated") {
325
+ clearWatchdog();
299
326
  console.log("Connected and waiting for EngineerOS runs.");
300
327
  reconnectDelay = 1_000;
301
328
  startPings();
@@ -345,10 +372,10 @@ async function connect() {
345
372
  await stopProcess(active.child);
346
373
  return;
347
374
  }
348
- if (message.type === "connector.revoked") {
349
- stopped = true;
350
- console.error("This connector was revoked in EngineerOS.");
351
- socket.close();
375
+ if (message.type === "connector.revoked") {
376
+ stopped = true;
377
+ console.error("This connector was revoked in EngineerOS.");
378
+ connection.close();
352
379
  return;
353
380
  }
354
381
  if (message.type === "run.error") {
@@ -364,9 +391,10 @@ async function connect() {
364
391
  console.error(`EngineerOS: ${message.message}`);
365
392
  }
366
393
  });
367
- socket.addEventListener("close", (event) => {
368
- clearConnectionWatchdog?.();
369
- clearInterval(pingTimer);
394
+ connection.addEventListener("close", (event) => {
395
+ clearWatchdog();
396
+ if (socket !== connection) return;
397
+ clearInterval(pingTimer);
370
398
  for (const promptState of activePrompts.values())
371
399
  void cancelPrompt(promptState);
372
400
  for (const assessmentState of activeAssessments.values()) {
@@ -382,8 +410,9 @@ async function connect() {
382
410
  if (stopped) return;
383
411
  scheduleReconnect();
384
412
  });
385
- socket.addEventListener("error", (event) => {
386
- lastConnectionError = describeWebSocketError(event);
413
+ connection.addEventListener("error", (event) => {
414
+ if (socket !== connection) return;
415
+ lastConnectionError = describeWebSocketError(event);
387
416
  console.error(
388
417
  `WebSocket connection to ${config.server_url} failed: ${lastConnectionError}`,
389
418
  );
@@ -662,9 +691,10 @@ async function executeAssessment(assignment, assessmentState) {
662
691
  let agentCompleted = false;
663
692
  let statusTicks = 0;
664
693
  let progress = 5;
665
- let accepted = false;
666
- let failureReported = false;
667
- let inactivityFailure = null;
694
+ let accepted = false;
695
+ let failureReported = false;
696
+ let inactivityFailure = null;
697
+ let checkpointSessionId = null;
668
698
  let lastReportedMilestone = null;
669
699
  const creditedMilestones = new Set();
670
700
  const isActiveAssessment = () =>
@@ -724,7 +754,21 @@ async function executeAssessment(assignment, assessmentState) {
724
754
  );
725
755
  }
726
756
  }, 15_000);
727
- const assessmentAgentCallbacks = {
757
+ const assessmentAgentCallbacks = {
758
+ onSession: async (sessionId) => {
759
+ if (!isActiveAssessment()) return;
760
+ checkpointSessionId = String(sessionId);
761
+ await persistAssessmentStageSession(
762
+ config.workspace,
763
+ assessmentId,
764
+ stage,
765
+ sessionId,
766
+ {
767
+ sourceRevision: assignment.source_revision,
768
+ targetHeadRevision: assignment.target_head_revision,
769
+ },
770
+ );
771
+ },
728
772
  onController: (controller) => {
729
773
  if (isActiveAssessment()) assessmentState.controller = controller;
730
774
  },
@@ -753,15 +797,16 @@ async function executeAssessment(assignment, assessmentState) {
753
797
  config,
754
798
  assessmentAgentCallbacks,
755
799
  {
756
- draftPath: path
757
- .join(
758
- ".engineeros",
759
- "assessments",
760
- String(assessmentId),
761
- candidate.report_file,
762
- )
763
- .split(path.sep)
764
- .join("/"),
800
+ draftPath: path
801
+ .relative(
802
+ config.workspace,
803
+ path.join(
804
+ assessmentRunDirectory(config.workspace, assessmentId),
805
+ candidate.report_file,
806
+ ),
807
+ )
808
+ .split(path.sep)
809
+ .join("/"),
765
810
  },
766
811
  );
767
812
  try {
@@ -773,20 +818,37 @@ async function executeAssessment(assignment, assessmentState) {
773
818
  config.workspace,
774
819
  assignment,
775
820
  );
776
- let result = await loadAssessmentStageResult(
777
- config.workspace,
778
- assessmentId,
779
- stage,
780
- );
781
- if (result) {
821
+ let result = await loadAssessmentStageResult(
822
+ config.workspace,
823
+ assessmentId,
824
+ stage,
825
+ {
826
+ expectedHeadRevision: assignment.target_head_revision,
827
+ expectedSourceRevision: assignment.source_revision,
828
+ },
829
+ );
830
+ if (result) {
782
831
  attachStoredCorrection(result, preparedAssignment);
783
832
  reportProgress("Recovered completed stage report from connector storage");
784
- } else {
785
- result = await executeWorkspaceAssessment(
786
- preparedAssignment,
787
- config,
788
- assessmentAgentCallbacks,
789
- );
833
+ } else {
834
+ checkpointSessionId = await loadAssessmentStageSession(
835
+ config.workspace,
836
+ assessmentId,
837
+ stage,
838
+ {
839
+ expectedHeadRevision: assignment.target_head_revision,
840
+ expectedSourceRevision: assignment.source_revision,
841
+ },
842
+ );
843
+ if (checkpointSessionId) {
844
+ reportProgress("Restoring interrupted agent session");
845
+ }
846
+ result = await executeWorkspaceAssessment(
847
+ preparedAssignment,
848
+ config,
849
+ assessmentAgentCallbacks,
850
+ { previousSessionId: checkpointSessionId },
851
+ );
790
852
  await persistAssessmentStageResult(
791
853
  config.workspace,
792
854
  assessmentId,
@@ -837,11 +899,12 @@ async function executeAssessment(assignment, assessmentState) {
837
899
  submit: deliverResult,
838
900
  payloadFor: (candidate) =>
839
901
  stage === "synthesis"
840
- ? assessmentCompletionBundle(
841
- config.workspace,
842
- assessmentId,
843
- candidate,
844
- )
902
+ ? assessmentCompletionBundle(
903
+ config.workspace,
904
+ assessmentId,
905
+ candidate,
906
+ { expectedSourceRevision: assignment.source_revision },
907
+ )
845
908
  : assessmentCheckpointPayload(candidate),
846
909
  rejectionFor: (response) =>
847
910
  describeRejectedResponse(response, "the assessment"),
@@ -883,28 +946,56 @@ async function executeAssessment(assignment, assessmentState) {
883
946
  await describeRejectedResponse(response, "the assessment"),
884
947
  );
885
948
  }
886
- accepted = true;
887
- if (stage === "synthesis") {
888
- await clearAssessmentSpool(config.workspace, assessmentId);
889
- console.log(
890
- `Workspace assessment ${assessmentId} was accepted by EngineerOS.`,
891
- );
949
+ accepted = true;
950
+ if (stage === "synthesis") {
951
+ const archivePath = await archiveCompletedAssessment(
952
+ config.workspace,
953
+ assessmentId,
954
+ assignment,
955
+ result,
956
+ );
957
+ console.log(
958
+ `Workspace assessment ${assessmentId} was accepted by EngineerOS and saved to ${path.relative(config.workspace, archivePath)}.`,
959
+ );
892
960
  } else {
893
961
  console.log(
894
962
  `Workspace assessment stage ${stage} was checkpointed by EngineerOS.`,
895
963
  );
896
964
  }
897
- } catch (error) {
898
- const message = inactivityFailure || protocolFailureMessage(error);
899
- if (isActiveAssessment()) {
900
- failureReported = sendAssessmentMessage({
901
- type: "workspace.assessment.failed",
902
- assessment_id: assessmentId,
903
- stage,
904
- message,
905
- });
906
- }
907
- console.error(message);
965
+ } catch (error) {
966
+ const message = inactivityFailure || protocolFailureMessage(error);
967
+ const resumeAttempts = Number(
968
+ assignment.connector_session_resume_attempts || 0,
969
+ );
970
+ const canResumeSession =
971
+ !inactivityFailure &&
972
+ checkpointSessionId &&
973
+ isRecoverableAssessmentAgentFailure(error) &&
974
+ resumeAttempts < 2;
975
+ if (isActiveAssessment() && canResumeSession) {
976
+ assessmentState.phase = "waiting";
977
+ assessmentState.lastMessage =
978
+ "Agent session interrupted; resuming checkpoint";
979
+ assessmentState.resumeAssignment = {
980
+ ...assignment,
981
+ connector_session_resume_attempts: resumeAttempts + 1,
982
+ };
983
+ const delayMs = 1_000 * 2 ** resumeAttempts;
984
+ console.warn(
985
+ `[assessment:${stage}] Agent session interrupted: ${message} ` +
986
+ `Resuming session ${checkpointSessionId} in ${Math.round(delayMs / 1_000)}s ` +
987
+ `(${resumeAttempts + 1}/2).`,
988
+ );
989
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
990
+ } else if (isActiveAssessment()) {
991
+ failureReported = sendAssessmentMessage({
992
+ type: "workspace.assessment.failed",
993
+ assessment_id: assessmentId,
994
+ stage,
995
+ message,
996
+ });
997
+ }
998
+ if (!canResumeSession) console.error(message);
908
999
  } finally {
909
1000
  clearInterval(heartbeat);
910
1001
  if (isActiveAssessment()) {
@@ -913,10 +1004,12 @@ async function executeAssessment(assignment, assessmentState) {
913
1004
  assessments,
914
1005
  assessmentState,
915
1006
  { accepted, failureReported },
916
- );
917
- if (replayQueued) {
918
- console.log(`Restarting interrupted assessment stage ${stage}.`);
919
- }
1007
+ );
1008
+ if (replayQueued) {
1009
+ console.log(
1010
+ `Resuming interrupted agent session for assessment stage ${stage} from its checkpoint.`,
1011
+ );
1012
+ }
920
1013
  pump();
921
1014
  }
922
1015
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engineeros/connector",
3
- "version": "0.15.3",
3
+ "version": "0.16.1",
4
4
  "description": "Connect a local coding agent to EngineerOS, using ACP when supported.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -201,19 +201,20 @@ class AcpRuntime {
201
201
  this.clearIdleTimer();
202
202
  const initialized = await this.ready;
203
203
  let session = this.sessions.get(sessionKey);
204
- if (!session) {
205
- session = await this.openSession(
204
+ if (!session) {
205
+ session = await this.openSession(
206
206
  sessionKey,
207
207
  previousSessionId,
208
208
  sandbox,
209
209
  profile,
210
210
  );
211
- callbacks.onEvent?.({
212
- type: "agent.connected",
213
- message: `Agent connected with protocol ${initialized.protocolVersion}`,
214
- });
215
- }
216
- if (this.turns.has(session.sessionId)) {
211
+ callbacks.onEvent?.({
212
+ type: "agent.connected",
213
+ message: `Agent connected with protocol ${initialized.protocolVersion}`,
214
+ });
215
+ }
216
+ await callbacks.onSession?.(session.sessionId);
217
+ if (this.turns.has(session.sessionId)) {
217
218
  throw new Error(
218
219
  "The agent is already answering another prompt in this session.",
219
220
  );
@@ -12,14 +12,18 @@ import path from "node:path";
12
12
 
13
13
  const LOCAL_REPORTS_MARKER = "<!-- ENGINEEROS_LOCAL_STAGE_REPORTS -->";
14
14
 
15
- export function assessmentRunDirectory(workspace, assessmentId) {
16
- return path.join(
17
- path.resolve(workspace),
18
- ".engineeros",
19
- "assessments",
20
- String(assessmentId),
21
- );
22
- }
15
+ export function assessmentRunDirectory(workspace, _assessmentId) {
16
+ return path.join(
17
+ path.resolve(workspace),
18
+ ".engineeros",
19
+ "assessment",
20
+ ".work",
21
+ );
22
+ }
23
+
24
+ export function assessmentArchiveDirectory(workspace) {
25
+ return path.join(path.resolve(workspace), ".engineeros", "assessment");
26
+ }
23
27
 
24
28
  export async function persistAssessmentStageResult(
25
29
  workspace,
@@ -33,12 +37,14 @@ export async function persistAssessmentStageResult(
33
37
  const basename = assessmentStageBasename(result.stage);
34
38
  const reportName = `${basename}.md`;
35
39
  const metadataName = `${basename}.result.json`;
36
- const report = String(result.report_markdown).trim();
37
- const metadata = {
38
- stage: result.stage,
39
- report_file: reportName,
40
- report_sha256: sha256(report),
41
- observed_head_revision: result.observed_head_revision ?? null,
40
+ const report = String(result.report_markdown).trim();
41
+ const metadata = {
42
+ assessment_id: assessmentId,
43
+ stage: result.stage,
44
+ report_file: reportName,
45
+ report_sha256: sha256(report),
46
+ source_revision: assignment.source_revision ?? null,
47
+ observed_head_revision: result.observed_head_revision ?? null,
42
48
  changed_files: result.changed_files ?? [],
43
49
  change_impact_markdown: result.change_impact_markdown ?? null,
44
50
  model: result.model ?? null,
@@ -53,23 +59,39 @@ export async function persistAssessmentStageResult(
53
59
  return { ...metadata, report_markdown: report };
54
60
  }
55
61
 
56
- export async function loadAssessmentStageResult(
57
- workspace,
58
- assessmentId,
59
- stage,
60
- ) {
62
+ export async function loadAssessmentStageResult(
63
+ workspace,
64
+ assessmentId,
65
+ stage,
66
+ { expectedHeadRevision, expectedSourceRevision } = {},
67
+ ) {
61
68
  const directory = assessmentRunDirectory(workspace, assessmentId);
62
69
  const metadataPath = path.join(
63
70
  directory,
64
71
  `${assessmentStageBasename(stage)}.result.json`,
65
72
  );
66
73
  try {
67
- const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
68
- if (metadata.stage !== stage) {
69
- throw new Error(
70
- `Assessment spool metadata does not match stage '${stage}'.`,
71
- );
72
- }
74
+ const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
75
+ if (metadata.assessment_id !== assessmentId) {
76
+ return null;
77
+ }
78
+ if (metadata.stage !== stage) {
79
+ throw new Error(
80
+ `Assessment spool metadata does not match stage '${stage}'.`,
81
+ );
82
+ }
83
+ if (
84
+ expectedHeadRevision &&
85
+ metadata.observed_head_revision !== expectedHeadRevision
86
+ ) {
87
+ return null;
88
+ }
89
+ if (
90
+ expectedSourceRevision &&
91
+ metadata.source_revision !== expectedSourceRevision
92
+ ) {
93
+ return null;
94
+ }
73
95
  const expectedReportFile = `${assessmentStageBasename(stage)}.md`;
74
96
  if (metadata.report_file !== expectedReportFile) {
75
97
  throw new Error(
@@ -89,7 +111,78 @@ export async function loadAssessmentStageResult(
89
111
  if (error?.code === "ENOENT") return null;
90
112
  throw error;
91
113
  }
92
- }
114
+ }
115
+
116
+ export async function persistAssessmentStageSession(
117
+ workspace,
118
+ assessmentId,
119
+ stage,
120
+ sessionId,
121
+ { sourceRevision, targetHeadRevision } = {},
122
+ ) {
123
+ const normalizedSessionId = String(sessionId || "").trim();
124
+ if (!normalizedSessionId) {
125
+ throw new Error(`Assessment stage '${stage}' returned an empty session id.`);
126
+ }
127
+ const directory = assessmentRunDirectory(workspace, assessmentId);
128
+ await mkdir(directory, { recursive: true, mode: 0o700 });
129
+ const checkpoint = {
130
+ assessment_id: assessmentId,
131
+ stage,
132
+ agent_session_id: normalizedSessionId,
133
+ source_revision: String(sourceRevision || "").trim() || null,
134
+ target_head_revision: String(targetHeadRevision || "").trim() || null,
135
+ };
136
+ await atomicWrite(
137
+ assessmentStageSessionPath(directory, stage),
138
+ `${JSON.stringify(checkpoint, null, 2)}\n`,
139
+ );
140
+ return checkpoint;
141
+ }
142
+
143
+ export async function loadAssessmentStageSession(
144
+ workspace,
145
+ assessmentId,
146
+ stage,
147
+ { expectedHeadRevision, expectedSourceRevision } = {},
148
+ ) {
149
+ const directory = assessmentRunDirectory(workspace, assessmentId);
150
+ try {
151
+ const checkpoint = JSON.parse(
152
+ await readFile(assessmentStageSessionPath(directory, stage), "utf8"),
153
+ );
154
+ if (checkpoint.assessment_id !== assessmentId) {
155
+ return null;
156
+ }
157
+ if (checkpoint.stage !== stage) {
158
+ throw new Error(
159
+ `Assessment session checkpoint does not match stage '${stage}'.`,
160
+ );
161
+ }
162
+ if (
163
+ expectedHeadRevision &&
164
+ checkpoint.target_head_revision !== expectedHeadRevision
165
+ ) {
166
+ return null;
167
+ }
168
+ if (
169
+ expectedSourceRevision &&
170
+ checkpoint.source_revision !== expectedSourceRevision
171
+ ) {
172
+ return null;
173
+ }
174
+ const sessionId = String(checkpoint.agent_session_id || "").trim();
175
+ if (!sessionId) {
176
+ throw new Error(
177
+ `Assessment session checkpoint for '${stage}' has no session id.`,
178
+ );
179
+ }
180
+ return sessionId;
181
+ } catch (error) {
182
+ if (error?.code === "ENOENT") return null;
183
+ throw error;
184
+ }
185
+ }
93
186
 
94
187
  export async function removeAssessmentStageResult(
95
188
  workspace,
@@ -112,7 +205,11 @@ export async function removeAssessmentStageResult(
112
205
  );
113
206
  }
114
207
 
115
- export async function assessmentStageResults(workspace, assessmentId) {
208
+ export async function assessmentStageResults(
209
+ workspace,
210
+ assessmentId,
211
+ { expectedHeadRevision, expectedSourceRevision } = {},
212
+ ) {
116
213
  const directory = assessmentRunDirectory(workspace, assessmentId);
117
214
  let entries;
118
215
  try {
@@ -128,11 +225,12 @@ export async function assessmentStageResults(workspace, assessmentId) {
128
225
  const metadata = JSON.parse(
129
226
  await readFile(path.join(directory, entry), "utf8"),
130
227
  );
131
- return loadAssessmentStageResult(
132
- workspace,
133
- assessmentId,
134
- metadata.stage,
135
- );
228
+ return loadAssessmentStageResult(
229
+ workspace,
230
+ assessmentId,
231
+ metadata.stage,
232
+ { expectedHeadRevision, expectedSourceRevision },
233
+ );
136
234
  }),
137
235
  );
138
236
  return results
@@ -144,7 +242,10 @@ export async function prepareLocalAssessmentAssignment(workspace, assignment) {
144
242
  const prompt = String(assignment.prompt_markdown || "");
145
243
  if (!prompt.includes(LOCAL_REPORTS_MARKER)) return assignment;
146
244
  const reports = (
147
- await assessmentStageResults(workspace, assignment.assessment_id)
245
+ await assessmentStageResults(workspace, assignment.assessment_id, {
246
+ expectedHeadRevision: assignment.target_head_revision,
247
+ expectedSourceRevision: assignment.source_revision,
248
+ })
148
249
  ).filter((result) => result.stage !== "synthesis");
149
250
  if (!reports.length) {
150
251
  throw new Error(
@@ -176,19 +277,71 @@ export async function prepareLocalAssessmentAssignment(workspace, assignment) {
176
277
  };
177
278
  }
178
279
 
179
- export async function assessmentCompletionBundle(
180
- workspace,
181
- assessmentId,
182
- synthesisResult,
183
- ) {
280
+ export async function assessmentCompletionBundle(
281
+ workspace,
282
+ assessmentId,
283
+ synthesisResult,
284
+ { expectedSourceRevision } = {},
285
+ ) {
184
286
  const reports = (
185
- await assessmentStageResults(workspace, assessmentId)
287
+ await assessmentStageResults(workspace, assessmentId, {
288
+ expectedHeadRevision: synthesisResult.observed_head_revision,
289
+ expectedSourceRevision,
290
+ })
186
291
  ).filter((result) => result.stage !== "synthesis");
187
292
  return {
188
293
  ...assessmentResultPayload(synthesisResult),
189
294
  reports: reports.map(assessmentResultPayload),
190
- };
191
- }
295
+ };
296
+ }
297
+
298
+ export async function archiveCompletedAssessment(
299
+ workspace,
300
+ assessmentId,
301
+ assignment,
302
+ synthesisResult,
303
+ ) {
304
+ const bundle = await assessmentCompletionBundle(
305
+ workspace,
306
+ assessmentId,
307
+ synthesisResult,
308
+ { expectedSourceRevision: assignment.source_revision },
309
+ );
310
+ const archiveDirectory = assessmentArchiveDirectory(workspace);
311
+ const isUpdate = assignment.is_assessment_update === true;
312
+ const target = isUpdate
313
+ ? path.join(
314
+ archiveDirectory,
315
+ "changes",
316
+ `${assessmentChangeName(assignment, synthesisResult, assessmentId)}.md`,
317
+ )
318
+ : path.join(archiveDirectory, "assessment.md");
319
+ const heading = isUpdate
320
+ ? "# Workspace Assessment Change Log"
321
+ : "# Workspace Assessment";
322
+ const stageReports = [...bundle.reports, assessmentResultPayload(synthesisResult)]
323
+ .map(
324
+ (report) =>
325
+ `## ${assessmentStageTitle(report.stage, report.report_markdown)}\n\n${nestedStageReport(report.report_markdown)}`,
326
+ )
327
+ .join("\n\n");
328
+ const content = [
329
+ heading,
330
+ "",
331
+ `- Inventory revision: \`${assignment.source_revision || "not available"}\``,
332
+ `- Git commit: \`${synthesisResult.observed_head_revision || "not available"}\``,
333
+ "",
334
+ stageReports,
335
+ "",
336
+ ].join("\n");
337
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
338
+ await atomicWrite(target, content);
339
+ await rm(assessmentRunDirectory(workspace, assessmentId), {
340
+ recursive: true,
341
+ force: true,
342
+ });
343
+ return target;
344
+ }
192
345
 
193
346
  export function assessmentCheckpointPayload(result) {
194
347
  return {
@@ -226,14 +379,59 @@ function validateLocalAssessmentResult(assignment, result) {
226
379
  }
227
380
  }
228
381
 
229
- function assessmentStageBasename(stage) {
382
+ function assessmentStageBasename(stage) {
230
383
  const slug =
231
384
  String(stage)
232
385
  .replace(/[^a-zA-Z0-9_-]+/g, "-")
233
386
  .replace(/^-+|-+$/g, "")
234
387
  .slice(0, 64) || "stage";
235
388
  return `${slug}-${sha256(String(stage)).slice(0, 12)}`;
236
- }
389
+ }
390
+
391
+ function assessmentStageTitle(stage, markdown) {
392
+ const stageKey = String(stage || "");
393
+ let namedStage = null;
394
+ let label = "";
395
+ if (stageKey.startsWith("capability:")) {
396
+ label = "Capability";
397
+ namedStage = String(markdown || "").match(/^### Capability:\s*(.+?)\s*$/m);
398
+ } else if (stageKey.startsWith("compliance:")) {
399
+ label = "Compliance";
400
+ namedStage = String(markdown || "").match(
401
+ /^### Compliance Framework:\s*(.+?)\s*$/m,
402
+ );
403
+ }
404
+ if (namedStage) {
405
+ return `${label} — ${namedStage[1].trim()}`;
406
+ }
407
+ return String(stage || "assessment")
408
+ .replace(/[:_-]+/g, " ")
409
+ .replace(/\b\w/g, (character) => character.toUpperCase());
410
+ }
411
+
412
+ function nestedStageReport(markdown) {
413
+ return String(markdown || "")
414
+ .trim()
415
+ .replace(/^# Assessment Stage:[^\n]*\n+/i, "")
416
+ .replace(/^\s*-\s+State:\s*.*\n?/gm, "")
417
+ .replace(/^(#{2,5}) /gm, "#$1 ");
418
+ }
419
+
420
+ function assessmentChangeName(assignment, synthesisResult, assessmentId) {
421
+ const headRevision = String(synthesisResult.observed_head_revision || "")
422
+ .trim()
423
+ .toLowerCase();
424
+ if (/^[a-f0-9]{7,64}$/.test(headRevision)) {
425
+ return headRevision.slice(0, 16);
426
+ }
427
+ return sha256(
428
+ String(assignment.source_revision || assessmentId),
429
+ ).slice(0, 16);
430
+ }
431
+
432
+ function assessmentStageSessionPath(directory, stage) {
433
+ return path.join(directory, `${assessmentStageBasename(stage)}.session.json`);
434
+ }
237
435
 
238
436
  function sha256(value) {
239
437
  return createHash("sha256").update(value).digest("hex");
@@ -217,8 +217,9 @@ export function launchCodexAppServer({
217
217
  model: profile.model || null,
218
218
  ephemeral: false,
219
219
  });
220
- threadId = threadResult.thread.id;
221
- callbacks.onEvent?.({ type: "thread.started", thread_id: threadId });
220
+ threadId = threadResult.thread.id;
221
+ await callbacks.onSession?.(threadId);
222
+ callbacks.onEvent?.({ type: "thread.started", thread_id: threadId });
222
223
  const turnResult = await request("turn/start", {
223
224
  threadId,
224
225
  input: [{ type: "text", text: prompt }],
@@ -1,13 +1,19 @@
1
1
  const PROTOCOL_FAILURE_MESSAGE_MAX_LENGTH = 2_000;
2
2
  const TRUNCATED_FAILURE_SUFFIX = "\n… [truncated by EngineerOS connector]";
3
3
 
4
- export function describeWebSocketError(event) {
4
+ export function describeWebSocketError(event) {
5
5
  return (
6
6
  event?.error?.message ||
7
7
  event?.message ||
8
8
  "The WebSocket connection failed without providing an error detail."
9
9
  );
10
- }
10
+ }
11
+
12
+ export function sendConnectionMessage(activeSocket, connection, message) {
13
+ if (activeSocket !== connection || connection.readyState !== 1) return false;
14
+ connection.send(JSON.stringify(message));
15
+ return true;
16
+ }
11
17
 
12
18
  export function protocolFailureMessage(error) {
13
19
  const raw = error instanceof Error ? error.message : String(error ?? "");
package/src/runner.mjs CHANGED
@@ -361,11 +361,12 @@ export function parseVerificationReport(
361
361
  });
362
362
  }
363
363
 
364
- export async function executeWorkspaceAssessment(
365
- assignment,
366
- config,
367
- callbacks,
368
- ) {
364
+ export async function executeWorkspaceAssessment(
365
+ assignment,
366
+ config,
367
+ callbacks,
368
+ { previousSessionId } = {},
369
+ ) {
369
370
  const execution = workspaceAssessmentExecution(assignment);
370
371
  const assessmentAcpContext =
371
372
  config.agent_protocol === "acp"
@@ -415,13 +416,16 @@ export async function executeWorkspaceAssessment(
415
416
  assignment.target_head_revision,
416
417
  )
417
418
  : { changedFiles: [], markdown: null };
418
- const prompt = changeImpact.markdown
419
- ? execution.prompt.replace(
420
- "<!-- ENGINEEROS_CHANGE_IMPACT -->",
421
- changeImpact.markdown,
422
- )
423
- : execution.prompt;
424
- const controller = launchAssessmentAgent(prompt);
419
+ const prompt = changeImpact.markdown
420
+ ? execution.prompt.replace(
421
+ "<!-- ENGINEEROS_CHANGE_IMPACT -->",
422
+ changeImpact.markdown,
423
+ )
424
+ : execution.prompt;
425
+ const controller = launchAssessmentAgent(
426
+ previousSessionId ? assessmentContinuationPrompt(prompt) : prompt,
427
+ previousSessionId,
428
+ );
425
429
  let completed = await controller.completed;
426
430
  const recovered = await recoverAssessmentStageOutput({
427
431
  completed,
@@ -1099,6 +1103,38 @@ export function takeWorkspaceAssessmentWave(queue, activeCount, limit) {
1099
1103
  return wave;
1100
1104
  }
1101
1105
 
1106
+ export function assessmentContinuationPrompt(originalPrompt) {
1107
+ return [
1108
+ "# Continue Interrupted EngineerOS Assessment",
1109
+ "",
1110
+ "Continue the existing assessment task in this agent session from its last completed step.",
1111
+ "Do not restart repository inspection or repeat work already present in the session context.",
1112
+ "Finish and return the complete structured Markdown report required by the original stage contract.",
1113
+ "If the provider could not restore prior context, use the original contract below to complete only the missing work.",
1114
+ "",
1115
+ "## Original Stage Contract",
1116
+ "",
1117
+ String(originalPrompt || "").trim(),
1118
+ ].join("\n");
1119
+ }
1120
+
1121
+ export function isRecoverableAssessmentAgentFailure(error) {
1122
+ const message = String(error?.message || error || "").toLowerCase();
1123
+ return [
1124
+ "acp connection closed",
1125
+ "acp agent ended the task with an error",
1126
+ "app-server connection closed",
1127
+ "app server connection closed",
1128
+ "connection reset",
1129
+ "econnreset",
1130
+ "broken pipe",
1131
+ "service unavailable",
1132
+ "too many requests",
1133
+ "rate limit",
1134
+ "overloaded",
1135
+ ].some((fragment) => message.includes(fragment));
1136
+ }
1137
+
1102
1138
  export function workspaceAssessmentWorkerLimit(value) {
1103
1139
  const parsed = Number(value ?? DEFAULT_ASSESSMENT_WORKERS);
1104
1140
  if (
@@ -1674,8 +1710,10 @@ function launchCodexProcess(
1674
1710
  child.stdin.end(prompt);
1675
1711
  let output = "";
1676
1712
  let buffer = "";
1677
- let finalMessage = "";
1678
- let sessionId = previousSessionId || "";
1713
+ let finalMessage = "";
1714
+ let sessionId = previousSessionId || "";
1715
+ let sessionCheckpoint = Promise.resolve();
1716
+ let sessionCheckpointError;
1679
1717
  child.stdout.setEncoding("utf8");
1680
1718
  child.stdout.on("data", (chunk) => {
1681
1719
  output += chunk;
@@ -1686,12 +1724,17 @@ function launchCodexProcess(
1686
1724
  if (!line.trim()) continue;
1687
1725
  try {
1688
1726
  const event = JSON.parse(line);
1689
- if (
1690
- event.type === "thread.started" &&
1691
- typeof event.thread_id === "string"
1692
- ) {
1693
- sessionId = event.thread_id;
1694
- }
1727
+ if (
1728
+ event.type === "thread.started" &&
1729
+ typeof event.thread_id === "string"
1730
+ ) {
1731
+ sessionId = event.thread_id;
1732
+ sessionCheckpoint = Promise.resolve()
1733
+ .then(() => callbacks.onSession?.(sessionId))
1734
+ .catch((error) => {
1735
+ sessionCheckpointError = error;
1736
+ });
1737
+ }
1695
1738
  if (
1696
1739
  event.type === "item.completed" &&
1697
1740
  event.item?.type === "agent_message" &&
@@ -1718,17 +1761,20 @@ function launchCodexProcess(
1718
1761
  });
1719
1762
  const completed = new Promise((resolve, reject) => {
1720
1763
  child.once("error", reject);
1721
- child.once("close", (code) => {
1722
- if (code === 0 && sessionId)
1723
- resolve({ output: output.slice(-20_000), finalMessage, sessionId });
1724
- else if (code === 0)
1725
- reject(
1726
- new Error(
1727
- "Agent completed without announcing a resumable session id.",
1728
- ),
1729
- );
1730
- else reject(new Error(codexFailureMessage(output, code)));
1731
- });
1764
+ child.once("close", (code) => {
1765
+ void sessionCheckpoint.then(() => {
1766
+ if (sessionCheckpointError) reject(sessionCheckpointError);
1767
+ else if (code === 0 && sessionId)
1768
+ resolve({ output: output.slice(-20_000), finalMessage, sessionId });
1769
+ else if (code === 0)
1770
+ reject(
1771
+ new Error(
1772
+ "Agent completed without announcing a resumable session id.",
1773
+ ),
1774
+ );
1775
+ else reject(new Error(codexFailureMessage(output, code)));
1776
+ });
1777
+ });
1732
1778
  });
1733
1779
  return { child, completed };
1734
1780
  }