doer-agent 0.3.5 → 0.3.6

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 (2) hide show
  1. package/dist/agent.js +190 -49
  2. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -18,6 +18,7 @@ const codexAuthRpcCodec = StringCodec();
18
18
  const settingsRpcCodec = StringCodec();
19
19
  const gitRpcCodec = StringCodec();
20
20
  const skillRpcCodec = StringCodec();
21
+ const runEventsCodec = StringCodec();
21
22
  const retainedRuns = new Map();
22
23
  const activeSessionWatchers = new Map();
23
24
  const sessionLineIndexCache = new Map();
@@ -30,6 +31,9 @@ function sanitizeUserId(userId) {
30
31
  function buildAgentRunRpcSubject(userId, agentId) {
31
32
  return `doer.agent.run.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
32
33
  }
34
+ function buildAgentRunEventsSubject(userId, agentId) {
35
+ return `doer.agent.run.events.${sanitizeUserId(userId)}.${agentId.trim()}`;
36
+ }
33
37
  function buildAgentSessionRpcSubject(userId, agentId) {
34
38
  return `doer.agent.session.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
35
39
  }
@@ -514,7 +518,7 @@ async function resolveRunStartLockPath(args) {
514
518
  if (typeof args.sessionId === "string" && args.sessionId.trim()) {
515
519
  return path.join(dir, `session__${sanitizeRunLockSegment(args.sessionId)}.lock`);
516
520
  }
517
- return path.join(dir, `run__${sanitizeRunLockSegment(args.runId)}.lock`);
521
+ return path.join(dir, "pending_new_session.lock");
518
522
  }
519
523
  async function claimRunStartSlot(args) {
520
524
  const lockPath = await resolveRunStartLockPath(args);
@@ -976,34 +980,136 @@ function cloneRunTask(task, _sinceSeq) {
976
980
  ...task,
977
981
  };
978
982
  }
979
- function extractCodexSessionMetadata(value) {
983
+ function buildImmediateRunChangedEvent(task) {
984
+ return {
985
+ type: "run.changed",
986
+ agentId: task.agentId,
987
+ sessionId: task.sessionId,
988
+ filePath: task.sessionFilePath,
989
+ runId: task.id,
990
+ updatedAt: task.updatedAt,
991
+ status: task.status,
992
+ cancelRequested: task.cancelRequested,
993
+ resultExitCode: task.resultExitCode,
994
+ resultSignal: task.resultSignal,
995
+ error: task.error,
996
+ finishedAt: task.finishedAt,
997
+ };
998
+ }
999
+ function publishImmediateRunChangedEvent(args) {
1000
+ args.nc.publish(buildAgentRunEventsSubject(args.userId, args.task.agentId), runEventsCodec.encode(JSON.stringify(buildImmediateRunChangedEvent(args.task))));
1001
+ }
1002
+ async function findSessionFilePathBySessionId(sessionId) {
1003
+ const targetSessionId = sessionId.trim();
1004
+ if (!targetSessionId) {
1005
+ return null;
1006
+ }
1007
+ let sessionsRootStat;
980
1008
  try {
981
- const parsed = JSON.parse(value);
982
- const lineType = typeof parsed.type === "string" ? parsed.type : "";
983
- if (!parsed.payload || typeof parsed.payload !== "object" || Array.isArray(parsed.payload)) {
984
- return { sessionId: null, sessionFilePath: null };
985
- }
986
- const payload = parsed.payload;
987
- const sessionIdCandidate = typeof payload.sessionId === "string" && payload.sessionId.trim()
988
- ? payload.sessionId.trim()
989
- : typeof payload.session_id === "string" && payload.session_id.trim()
990
- ? payload.session_id.trim()
991
- : typeof payload.id === "string" && payload.id.trim() && (lineType === "session_meta" || lineType === "session.started")
992
- ? payload.id.trim()
993
- : null;
994
- const filePathCandidate = typeof payload.rollout_path === "string" && payload.rollout_path.trim()
995
- ? payload.rollout_path.trim()
996
- : typeof payload.filePath === "string" && payload.filePath.trim()
997
- ? payload.filePath.trim()
998
- : null;
999
- return {
1000
- sessionId: sessionIdCandidate,
1001
- sessionFilePath: filePathCandidate,
1002
- };
1009
+ sessionsRootStat = await stat(getSessionsRootPath());
1010
+ }
1011
+ catch {
1012
+ return null;
1013
+ }
1014
+ if (!sessionsRootStat.isDirectory()) {
1015
+ return null;
1016
+ }
1017
+ const files = await collectSessionJsonlFiles(getSessionsRootPath());
1018
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
1019
+ for (const file of files) {
1020
+ let fileHandle = null;
1021
+ try {
1022
+ fileHandle = await open(file.filePath, "r");
1023
+ const entryStat = await fileHandle.stat();
1024
+ const firstLine = await readFirstLine(fileHandle, entryStat.size);
1025
+ if (!firstLine) {
1026
+ continue;
1027
+ }
1028
+ const parsed = JSON.parse(firstLine);
1029
+ const candidateMeta = parsed && parsed.type === "session_meta" && isObjectRecord(parsed.payload)
1030
+ ? parsed.payload
1031
+ : isObjectRecord(parsed.session_meta)
1032
+ ? parsed.session_meta
1033
+ : isObjectRecord(parsed.sessionMeta)
1034
+ ? parsed.sessionMeta
1035
+ : isObjectRecord(parsed.meta)
1036
+ ? parsed.meta
1037
+ : isObjectRecord(parsed.payload)
1038
+ ? parsed.payload
1039
+ : parsed;
1040
+ const candidateId = pickSessionString(candidateMeta.sessionId, candidateMeta.session_id, candidateMeta.id);
1041
+ if (candidateId === targetSessionId) {
1042
+ return file.filePath;
1043
+ }
1044
+ }
1045
+ catch {
1046
+ // ignore malformed session files
1047
+ }
1048
+ finally {
1049
+ await fileHandle?.close().catch(() => undefined);
1050
+ }
1051
+ }
1052
+ return null;
1053
+ }
1054
+ async function readSessionIdFromSessionFile(filePath) {
1055
+ let fileHandle = null;
1056
+ try {
1057
+ fileHandle = await open(filePath, "r");
1058
+ const entryStat = await fileHandle.stat();
1059
+ const firstLine = await readFirstLine(fileHandle, entryStat.size);
1060
+ if (!firstLine) {
1061
+ return null;
1062
+ }
1063
+ const parsed = JSON.parse(firstLine);
1064
+ const candidateMeta = parsed && parsed.type === "session_meta" && isObjectRecord(parsed.payload)
1065
+ ? parsed.payload
1066
+ : isObjectRecord(parsed.session_meta)
1067
+ ? parsed.session_meta
1068
+ : isObjectRecord(parsed.sessionMeta)
1069
+ ? parsed.sessionMeta
1070
+ : isObjectRecord(parsed.meta)
1071
+ ? parsed.meta
1072
+ : isObjectRecord(parsed.payload)
1073
+ ? parsed.payload
1074
+ : parsed;
1075
+ return pickSessionString(candidateMeta.sessionId, candidateMeta.session_id, candidateMeta.id);
1003
1076
  }
1004
1077
  catch {
1005
- return { sessionId: null, sessionFilePath: null };
1078
+ return null;
1079
+ }
1080
+ finally {
1081
+ await fileHandle?.close().catch(() => undefined);
1082
+ }
1083
+ }
1084
+ async function detectPendingRunSession(knownFilePaths) {
1085
+ const sessionsRoot = getSessionsRootPath();
1086
+ let sessionsRootStat;
1087
+ try {
1088
+ sessionsRootStat = await stat(sessionsRoot);
1089
+ }
1090
+ catch {
1091
+ return null;
1092
+ }
1093
+ if (!sessionsRootStat.isDirectory()) {
1094
+ return null;
1095
+ }
1096
+ const files = await collectSessionJsonlFiles(sessionsRoot);
1097
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs || a.filePath.localeCompare(b.filePath));
1098
+ for (const file of files) {
1099
+ if (knownFilePaths.has(file.filePath)) {
1100
+ continue;
1101
+ }
1102
+ const sessionId = await readSessionIdFromSessionFile(file.filePath);
1103
+ if (!sessionId) {
1104
+ continue;
1105
+ }
1106
+ knownFilePaths.add(file.filePath);
1107
+ return {
1108
+ sessionId,
1109
+ sessionFilePath: file.filePath,
1110
+ };
1006
1111
  }
1112
+ return null;
1007
1113
  }
1008
1114
  async function updateRunSessionMetadata(task, metadata) {
1009
1115
  let changed = false;
@@ -1016,11 +1122,25 @@ async function updateRunSessionMetadata(task, metadata) {
1016
1122
  task.sessionFilePath = metadata.sessionFilePath.trim();
1017
1123
  changed = true;
1018
1124
  }
1125
+ if (!task.sessionFilePath && task.sessionId) {
1126
+ const resolvedSessionFilePath = await findSessionFilePathBySessionId(task.sessionId).catch(() => null);
1127
+ if (resolvedSessionFilePath) {
1128
+ task.sessionFilePath = resolvedSessionFilePath;
1129
+ changed = true;
1130
+ }
1131
+ }
1019
1132
  if (!changed) {
1020
1133
  return;
1021
1134
  }
1022
1135
  task.updatedAt = formatLocalTimestamp();
1023
1136
  await persistRunTask(task).catch(() => undefined);
1137
+ if (metadata.nc) {
1138
+ publishImmediateRunChangedEvent({
1139
+ nc: metadata.nc,
1140
+ userId: task.userId,
1141
+ task,
1142
+ });
1143
+ }
1024
1144
  if (!previousSessionId && task.sessionId) {
1025
1145
  await updateRunStartSlotSession({
1026
1146
  runId: task.id,
@@ -1132,46 +1252,60 @@ async function startManagedRun(args) {
1132
1252
  startedAt: now,
1133
1253
  finishedAt: null,
1134
1254
  };
1135
- let stdoutBuffer = "";
1136
- const recordChunk = (stream, chunk) => {
1137
- writeRunStream(task.id, stream, chunk);
1138
- if (stream !== "stdout" || (task.sessionId && task.sessionFilePath)) {
1255
+ let pendingSessionPollClosed = false;
1256
+ let pendingSessionPollTimer = null;
1257
+ const knownPendingSessionFiles = new Set();
1258
+ const stopPendingSessionPoll = () => {
1259
+ pendingSessionPollClosed = true;
1260
+ if (pendingSessionPollTimer) {
1261
+ clearInterval(pendingSessionPollTimer);
1262
+ pendingSessionPollTimer = null;
1263
+ }
1264
+ };
1265
+ const pollPendingSession = async () => {
1266
+ if (pendingSessionPollClosed || task.sessionId) {
1267
+ stopPendingSessionPoll();
1139
1268
  return;
1140
1269
  }
1141
- stdoutBuffer += chunk;
1142
- const lines = stdoutBuffer.split(/\r?\n/);
1143
- stdoutBuffer = lines.pop() ?? "";
1144
- for (const line of lines) {
1145
- const trimmed = line.trim();
1146
- if (!trimmed) {
1147
- continue;
1148
- }
1149
- const metadata = extractCodexSessionMetadata(trimmed);
1150
- if (metadata.sessionId || metadata.sessionFilePath) {
1151
- void updateRunSessionMetadata(task, metadata);
1152
- }
1270
+ const detected = await detectPendingRunSession(knownPendingSessionFiles).catch(() => null);
1271
+ if (!detected) {
1272
+ return;
1273
+ }
1274
+ await updateRunSessionMetadata(task, {
1275
+ sessionId: detected.sessionId,
1276
+ sessionFilePath: detected.sessionFilePath,
1277
+ nc: args.nc,
1278
+ }).catch(() => undefined);
1279
+ if (task.sessionId) {
1280
+ stopPendingSessionPoll();
1153
1281
  }
1154
1282
  };
1155
- child.stdout.on("data", (chunk) => recordChunk("stdout", chunk));
1156
- child.stderr.on("data", (chunk) => recordChunk("stderr", chunk));
1283
+ if (!task.sessionId) {
1284
+ const existingFiles = await collectSessionJsonlFiles(getSessionsRootPath()).catch(() => []);
1285
+ for (const file of existingFiles) {
1286
+ knownPendingSessionFiles.add(file.filePath);
1287
+ }
1288
+ pendingSessionPollTimer = setInterval(() => {
1289
+ void pollPendingSession();
1290
+ }, 1000);
1291
+ }
1292
+ child.stdout.on("data", (chunk) => writeRunStream(task.id, "stdout", chunk));
1293
+ child.stderr.on("data", (chunk) => writeRunStream(task.id, "stderr", chunk));
1157
1294
  child.once("error", (error) => {
1295
+ stopPendingSessionPoll();
1158
1296
  const message = error instanceof Error ? error.message : String(error);
1159
1297
  task.status = "failed";
1160
1298
  task.error = message;
1161
1299
  task.finishedAt = formatLocalTimestamp();
1162
1300
  persistRetainedRun(task);
1301
+ publishImmediateRunChangedEvent({ nc: args.nc, userId: task.userId, task });
1163
1302
  void removeRunTask(task.id).catch(() => undefined);
1164
1303
  void releaseRunStartSlot({ runId: task.id, sessionId: task.sessionId }).catch(() => undefined);
1165
1304
  void prepared.codexAuthCleanup().catch(() => undefined);
1166
1305
  writeRunStatus(task.id, `failed error=${message}`);
1167
1306
  });
1168
1307
  child.once("close", async (code, signal) => {
1169
- if (stdoutBuffer.trim() && (!task.sessionId || !task.sessionFilePath)) {
1170
- const metadata = extractCodexSessionMetadata(stdoutBuffer.trim());
1171
- if (metadata.sessionId || metadata.sessionFilePath) {
1172
- void updateRunSessionMetadata(task, metadata);
1173
- }
1174
- }
1308
+ stopPendingSessionPoll();
1175
1309
  const latest = await getStoredRun(task.id).catch(() => null);
1176
1310
  if (latest?.cancelRequested) {
1177
1311
  task.cancelRequested = true;
@@ -1182,6 +1316,7 @@ async function startManagedRun(args) {
1182
1316
  task.status = task.cancelRequested ? "canceled" : (task.resultExitCode ?? 1) === 0 ? "completed" : "failed";
1183
1317
  task.error = task.status === "failed" ? `Command exited with code ${task.resultExitCode ?? "null"}` : null;
1184
1318
  persistRetainedRun(task);
1319
+ publishImmediateRunChangedEvent({ nc: args.nc, userId: task.userId, task });
1185
1320
  void removeRunTask(task.id).catch(() => undefined);
1186
1321
  void releaseRunStartSlot({ runId: task.id, sessionId: task.sessionId }).catch(() => undefined);
1187
1322
  void prepared.codexAuthCleanup().catch(() => undefined);
@@ -1189,7 +1324,11 @@ async function startManagedRun(args) {
1189
1324
  });
1190
1325
  persistRetainedRun(task);
1191
1326
  void persistRunTask(task).catch(() => undefined);
1327
+ publishImmediateRunChangedEvent({ nc: args.nc, userId: task.userId, task });
1192
1328
  writeRunStatus(task.id, `started requestId=${args.requestId} cwd=${prepared.taskWorkspace}`);
1329
+ if (!task.sessionId) {
1330
+ void pollPendingSession();
1331
+ }
1193
1332
  return cloneRunTask(task);
1194
1333
  }
1195
1334
  function shellSingleQuote(value) {
@@ -2204,6 +2343,7 @@ async function handleRunRpcMessage(args) {
2204
2343
  serverBaseUrl: args.serverBaseUrl,
2205
2344
  userId: args.userId,
2206
2345
  agentId: args.agentId,
2346
+ nc: args.jetstream.nc,
2207
2347
  sessionId: request.sessionId,
2208
2348
  codexArgs: buildManagedCodexArgs({
2209
2349
  prompt: request.prompt ?? "",
@@ -2253,6 +2393,7 @@ async function handleRunRpcMessage(args) {
2253
2393
  target.cancelRequested = true;
2254
2394
  target.updatedAt = formatLocalTimestamp();
2255
2395
  await persistRunTask(target);
2396
+ publishImmediateRunChangedEvent({ nc: args.jetstream.nc, userId: target.userId, task: target });
2256
2397
  writeRunStatus(target.id, `cancel requested pid=${target.processPid}`);
2257
2398
  sendSignalToPid(target.processPid, "SIGINT");
2258
2399
  const task = cloneRunTask(target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",