doer-agent 0.3.5 → 0.4.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 (2) hide show
  1. package/dist/agent.js +274 -64
  2. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -9,6 +9,8 @@ const DEFAULT_SERVER_BASE_URL = "https://doer.cranix.net";
9
9
  const AGENT_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
10
10
  const AGENT_PROJECT_DIR = path.join(AGENT_MODULE_DIR, "..");
11
11
  const AGENT_PACKAGE_JSON_PATH = path.join(AGENT_PROJECT_DIR, "package.json");
12
+ const HEARTBEAT_INTERVAL_MS = 5_000;
13
+ const HEARTBEAT_FAILURE_THRESHOLD = 3;
12
14
  let activeTaskLogContext = null;
13
15
  let workspaceRootOverride = null;
14
16
  const fsRpcCodec = StringCodec();
@@ -18,6 +20,7 @@ const codexAuthRpcCodec = StringCodec();
18
20
  const settingsRpcCodec = StringCodec();
19
21
  const gitRpcCodec = StringCodec();
20
22
  const skillRpcCodec = StringCodec();
23
+ const runEventsCodec = StringCodec();
21
24
  const retainedRuns = new Map();
22
25
  const activeSessionWatchers = new Map();
23
26
  const sessionLineIndexCache = new Map();
@@ -30,6 +33,9 @@ function sanitizeUserId(userId) {
30
33
  function buildAgentRunRpcSubject(userId, agentId) {
31
34
  return `doer.agent.run.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
32
35
  }
36
+ function buildAgentRunEventsSubject(userId, agentId) {
37
+ return `doer.agent.run.events.${sanitizeUserId(userId)}.${agentId.trim()}`;
38
+ }
33
39
  function buildAgentSessionRpcSubject(userId, agentId) {
34
40
  return `doer.agent.session.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
35
41
  }
@@ -514,7 +520,7 @@ async function resolveRunStartLockPath(args) {
514
520
  if (typeof args.sessionId === "string" && args.sessionId.trim()) {
515
521
  return path.join(dir, `session__${sanitizeRunLockSegment(args.sessionId)}.lock`);
516
522
  }
517
- return path.join(dir, `run__${sanitizeRunLockSegment(args.runId)}.lock`);
523
+ return path.join(dir, "pending_new_session.lock");
518
524
  }
519
525
  async function claimRunStartSlot(args) {
520
526
  const lockPath = await resolveRunStartLockPath(args);
@@ -601,10 +607,13 @@ function resolveAgentSettingsDir() {
601
607
  function resolveAgentSettingsFilePath() {
602
608
  return path.join(resolveAgentSettingsDir(), "config.json");
603
609
  }
610
+ function resolveAgentModelInstructionsFilePath() {
611
+ return path.join(resolveAgentSettingsDir(), "model-instructions.md");
612
+ }
604
613
  function createDefaultAgentSettingsConfig() {
605
614
  return {
606
615
  general: {
607
- firstTurnPrompt: null,
616
+ personality: "pragmatic",
608
617
  },
609
618
  codex: {
610
619
  model: "gpt-5.4",
@@ -667,6 +676,9 @@ function normalizeNullableString(value) {
667
676
  const trimmed = value.trim();
668
677
  return trimmed ? trimmed : null;
669
678
  }
679
+ function normalizeCodexPersonality(value, fallback) {
680
+ return value === "friendly" || value === "pragmatic" ? value : fallback;
681
+ }
670
682
  function normalizeAgentSettingsConfig(value, fallback) {
671
683
  const base = fallback ?? createDefaultAgentSettingsConfig();
672
684
  const raw = value && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -681,7 +693,7 @@ function normalizeAgentSettingsConfig(value, fallback) {
681
693
  const figma = raw.figma && typeof raw.figma === "object" ? raw.figma : {};
682
694
  return {
683
695
  general: {
684
- firstTurnPrompt: normalizeNullableString(general.firstTurnPrompt) ?? base.general.firstTurnPrompt,
696
+ personality: normalizeCodexPersonality(general.personality, base.general.personality),
685
697
  },
686
698
  codex: {
687
699
  model: typeof codex.model === "string" && codex.model.trim() ? codex.model.trim() : base.codex.model,
@@ -753,6 +765,20 @@ async function writeAgentSettingsConfig(config) {
753
765
  await mkdir(dir, { recursive: true });
754
766
  await writeFile(resolveAgentSettingsFilePath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
755
767
  }
768
+ async function readAgentModelInstructions() {
769
+ const raw = await readFile(resolveAgentModelInstructionsFilePath(), "utf8").catch(() => "");
770
+ return raw.trim() ? raw : null;
771
+ }
772
+ async function writeAgentModelInstructions(value) {
773
+ const filePath = resolveAgentModelInstructionsFilePath();
774
+ const nextValue = typeof value === "string" ? value.trim() : "";
775
+ if (!nextValue) {
776
+ await unlink(filePath).catch(() => undefined);
777
+ return;
778
+ }
779
+ await mkdir(resolveAgentSettingsDir(), { recursive: true });
780
+ await writeFile(filePath, value ?? "", "utf8");
781
+ }
756
782
  function maskSecretPreview(secret) {
757
783
  if (secret.length <= 6) {
758
784
  return `${secret.slice(0, 1)}***${secret.slice(-1)}`;
@@ -765,7 +791,7 @@ function toMaskedSecret(value) {
765
791
  }
766
792
  return { has: true, masked: maskSecretPreview(value), length: value.length };
767
793
  }
768
- function toAgentSettingsPublic(config) {
794
+ async function toAgentSettingsPublic(config) {
769
795
  const realtimeKey = toMaskedSecret(config.realtime.apiKey);
770
796
  const gitOauth = toMaskedSecret(config.git.oauthToken);
771
797
  const awsSecret = toMaskedSecret(config.aws.secretAccessKey);
@@ -774,9 +800,11 @@ function toAgentSettingsPublic(config) {
774
800
  const notionToken = toMaskedSecret(config.notion.apiToken);
775
801
  const slackToken = toMaskedSecret(config.slack.botToken);
776
802
  const figmaToken = toMaskedSecret(config.figma.apiToken);
803
+ const customInstructions = await readAgentModelInstructions();
777
804
  return {
778
805
  general: {
779
- firstTurnPrompt: config.general.firstTurnPrompt,
806
+ personality: config.general.personality,
807
+ customInstructions,
780
808
  },
781
809
  codex: {
782
810
  model: config.codex.model,
@@ -868,7 +896,7 @@ function normalizeAgentSettingsPatch(value) {
868
896
  assignNested(section, key, raw[flatKey]);
869
897
  delete patch[flatKey];
870
898
  };
871
- move("firstTurnPrompt", "general", "firstTurnPrompt");
899
+ move("personality", "general", "personality");
872
900
  move("codexModel", "codex", "model");
873
901
  move("codexAuthMode", "codex", "authMode");
874
902
  move("realtimeModel", "realtime", "model");
@@ -976,34 +1004,136 @@ function cloneRunTask(task, _sinceSeq) {
976
1004
  ...task,
977
1005
  };
978
1006
  }
979
- function extractCodexSessionMetadata(value) {
1007
+ function buildImmediateRunChangedEvent(task) {
1008
+ return {
1009
+ type: "run.changed",
1010
+ agentId: task.agentId,
1011
+ sessionId: task.sessionId,
1012
+ filePath: task.sessionFilePath,
1013
+ runId: task.id,
1014
+ updatedAt: task.updatedAt,
1015
+ status: task.status,
1016
+ cancelRequested: task.cancelRequested,
1017
+ resultExitCode: task.resultExitCode,
1018
+ resultSignal: task.resultSignal,
1019
+ error: task.error,
1020
+ finishedAt: task.finishedAt,
1021
+ };
1022
+ }
1023
+ function publishImmediateRunChangedEvent(args) {
1024
+ args.nc.publish(buildAgentRunEventsSubject(args.userId, args.task.agentId), runEventsCodec.encode(JSON.stringify(buildImmediateRunChangedEvent(args.task))));
1025
+ }
1026
+ async function findSessionFilePathBySessionId(sessionId) {
1027
+ const targetSessionId = sessionId.trim();
1028
+ if (!targetSessionId) {
1029
+ return null;
1030
+ }
1031
+ let sessionsRootStat;
980
1032
  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
- };
1033
+ sessionsRootStat = await stat(getSessionsRootPath());
1003
1034
  }
1004
1035
  catch {
1005
- return { sessionId: null, sessionFilePath: null };
1036
+ return null;
1006
1037
  }
1038
+ if (!sessionsRootStat.isDirectory()) {
1039
+ return null;
1040
+ }
1041
+ const files = await collectSessionJsonlFiles(getSessionsRootPath());
1042
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
1043
+ for (const file of files) {
1044
+ let fileHandle = null;
1045
+ try {
1046
+ fileHandle = await open(file.filePath, "r");
1047
+ const entryStat = await fileHandle.stat();
1048
+ const firstLine = await readFirstLine(fileHandle, entryStat.size);
1049
+ if (!firstLine) {
1050
+ continue;
1051
+ }
1052
+ const parsed = JSON.parse(firstLine);
1053
+ const candidateMeta = parsed && parsed.type === "session_meta" && isObjectRecord(parsed.payload)
1054
+ ? parsed.payload
1055
+ : isObjectRecord(parsed.session_meta)
1056
+ ? parsed.session_meta
1057
+ : isObjectRecord(parsed.sessionMeta)
1058
+ ? parsed.sessionMeta
1059
+ : isObjectRecord(parsed.meta)
1060
+ ? parsed.meta
1061
+ : isObjectRecord(parsed.payload)
1062
+ ? parsed.payload
1063
+ : parsed;
1064
+ const candidateId = pickSessionString(candidateMeta.sessionId, candidateMeta.session_id, candidateMeta.id);
1065
+ if (candidateId === targetSessionId) {
1066
+ return file.filePath;
1067
+ }
1068
+ }
1069
+ catch {
1070
+ // ignore malformed session files
1071
+ }
1072
+ finally {
1073
+ await fileHandle?.close().catch(() => undefined);
1074
+ }
1075
+ }
1076
+ return null;
1077
+ }
1078
+ async function readSessionIdFromSessionFile(filePath) {
1079
+ let fileHandle = null;
1080
+ try {
1081
+ fileHandle = await open(filePath, "r");
1082
+ const entryStat = await fileHandle.stat();
1083
+ const firstLine = await readFirstLine(fileHandle, entryStat.size);
1084
+ if (!firstLine) {
1085
+ return null;
1086
+ }
1087
+ const parsed = JSON.parse(firstLine);
1088
+ const candidateMeta = parsed && parsed.type === "session_meta" && isObjectRecord(parsed.payload)
1089
+ ? parsed.payload
1090
+ : isObjectRecord(parsed.session_meta)
1091
+ ? parsed.session_meta
1092
+ : isObjectRecord(parsed.sessionMeta)
1093
+ ? parsed.sessionMeta
1094
+ : isObjectRecord(parsed.meta)
1095
+ ? parsed.meta
1096
+ : isObjectRecord(parsed.payload)
1097
+ ? parsed.payload
1098
+ : parsed;
1099
+ return pickSessionString(candidateMeta.sessionId, candidateMeta.session_id, candidateMeta.id);
1100
+ }
1101
+ catch {
1102
+ return null;
1103
+ }
1104
+ finally {
1105
+ await fileHandle?.close().catch(() => undefined);
1106
+ }
1107
+ }
1108
+ async function detectPendingRunSession(knownFilePaths) {
1109
+ const sessionsRoot = getSessionsRootPath();
1110
+ let sessionsRootStat;
1111
+ try {
1112
+ sessionsRootStat = await stat(sessionsRoot);
1113
+ }
1114
+ catch {
1115
+ return null;
1116
+ }
1117
+ if (!sessionsRootStat.isDirectory()) {
1118
+ return null;
1119
+ }
1120
+ const files = await collectSessionJsonlFiles(sessionsRoot);
1121
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs || a.filePath.localeCompare(b.filePath));
1122
+ for (const file of files) {
1123
+ if (knownFilePaths.has(file.filePath)) {
1124
+ continue;
1125
+ }
1126
+ const sessionId = await readSessionIdFromSessionFile(file.filePath);
1127
+ if (!sessionId) {
1128
+ continue;
1129
+ }
1130
+ knownFilePaths.add(file.filePath);
1131
+ return {
1132
+ sessionId,
1133
+ sessionFilePath: file.filePath,
1134
+ };
1135
+ }
1136
+ return null;
1007
1137
  }
1008
1138
  async function updateRunSessionMetadata(task, metadata) {
1009
1139
  let changed = false;
@@ -1016,11 +1146,25 @@ async function updateRunSessionMetadata(task, metadata) {
1016
1146
  task.sessionFilePath = metadata.sessionFilePath.trim();
1017
1147
  changed = true;
1018
1148
  }
1149
+ if (!task.sessionFilePath && task.sessionId) {
1150
+ const resolvedSessionFilePath = await findSessionFilePathBySessionId(task.sessionId).catch(() => null);
1151
+ if (resolvedSessionFilePath) {
1152
+ task.sessionFilePath = resolvedSessionFilePath;
1153
+ changed = true;
1154
+ }
1155
+ }
1019
1156
  if (!changed) {
1020
1157
  return;
1021
1158
  }
1022
1159
  task.updatedAt = formatLocalTimestamp();
1023
1160
  await persistRunTask(task).catch(() => undefined);
1161
+ if (metadata.nc) {
1162
+ publishImmediateRunChangedEvent({
1163
+ nc: metadata.nc,
1164
+ userId: task.userId,
1165
+ task,
1166
+ });
1167
+ }
1024
1168
  if (!previousSessionId && task.sessionId) {
1025
1169
  await updateRunStartSlotSession({
1026
1170
  runId: task.id,
@@ -1132,46 +1276,60 @@ async function startManagedRun(args) {
1132
1276
  startedAt: now,
1133
1277
  finishedAt: null,
1134
1278
  };
1135
- let stdoutBuffer = "";
1136
- const recordChunk = (stream, chunk) => {
1137
- writeRunStream(task.id, stream, chunk);
1138
- if (stream !== "stdout" || (task.sessionId && task.sessionFilePath)) {
1279
+ let pendingSessionPollClosed = false;
1280
+ let pendingSessionPollTimer = null;
1281
+ const knownPendingSessionFiles = new Set();
1282
+ const stopPendingSessionPoll = () => {
1283
+ pendingSessionPollClosed = true;
1284
+ if (pendingSessionPollTimer) {
1285
+ clearInterval(pendingSessionPollTimer);
1286
+ pendingSessionPollTimer = null;
1287
+ }
1288
+ };
1289
+ const pollPendingSession = async () => {
1290
+ if (pendingSessionPollClosed || task.sessionId) {
1291
+ stopPendingSessionPoll();
1139
1292
  return;
1140
1293
  }
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
- }
1294
+ const detected = await detectPendingRunSession(knownPendingSessionFiles).catch(() => null);
1295
+ if (!detected) {
1296
+ return;
1297
+ }
1298
+ await updateRunSessionMetadata(task, {
1299
+ sessionId: detected.sessionId,
1300
+ sessionFilePath: detected.sessionFilePath,
1301
+ nc: args.nc,
1302
+ }).catch(() => undefined);
1303
+ if (task.sessionId) {
1304
+ stopPendingSessionPoll();
1153
1305
  }
1154
1306
  };
1155
- child.stdout.on("data", (chunk) => recordChunk("stdout", chunk));
1156
- child.stderr.on("data", (chunk) => recordChunk("stderr", chunk));
1307
+ if (!task.sessionId) {
1308
+ const existingFiles = await collectSessionJsonlFiles(getSessionsRootPath()).catch(() => []);
1309
+ for (const file of existingFiles) {
1310
+ knownPendingSessionFiles.add(file.filePath);
1311
+ }
1312
+ pendingSessionPollTimer = setInterval(() => {
1313
+ void pollPendingSession();
1314
+ }, 1000);
1315
+ }
1316
+ child.stdout.on("data", (chunk) => writeRunStream(task.id, "stdout", chunk));
1317
+ child.stderr.on("data", (chunk) => writeRunStream(task.id, "stderr", chunk));
1157
1318
  child.once("error", (error) => {
1319
+ stopPendingSessionPoll();
1158
1320
  const message = error instanceof Error ? error.message : String(error);
1159
1321
  task.status = "failed";
1160
1322
  task.error = message;
1161
1323
  task.finishedAt = formatLocalTimestamp();
1162
1324
  persistRetainedRun(task);
1325
+ publishImmediateRunChangedEvent({ nc: args.nc, userId: task.userId, task });
1163
1326
  void removeRunTask(task.id).catch(() => undefined);
1164
1327
  void releaseRunStartSlot({ runId: task.id, sessionId: task.sessionId }).catch(() => undefined);
1165
1328
  void prepared.codexAuthCleanup().catch(() => undefined);
1166
1329
  writeRunStatus(task.id, `failed error=${message}`);
1167
1330
  });
1168
1331
  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
- }
1332
+ stopPendingSessionPoll();
1175
1333
  const latest = await getStoredRun(task.id).catch(() => null);
1176
1334
  if (latest?.cancelRequested) {
1177
1335
  task.cancelRequested = true;
@@ -1182,6 +1340,7 @@ async function startManagedRun(args) {
1182
1340
  task.status = task.cancelRequested ? "canceled" : (task.resultExitCode ?? 1) === 0 ? "completed" : "failed";
1183
1341
  task.error = task.status === "failed" ? `Command exited with code ${task.resultExitCode ?? "null"}` : null;
1184
1342
  persistRetainedRun(task);
1343
+ publishImmediateRunChangedEvent({ nc: args.nc, userId: task.userId, task });
1185
1344
  void removeRunTask(task.id).catch(() => undefined);
1186
1345
  void releaseRunStartSlot({ runId: task.id, sessionId: task.sessionId }).catch(() => undefined);
1187
1346
  void prepared.codexAuthCleanup().catch(() => undefined);
@@ -1189,7 +1348,11 @@ async function startManagedRun(args) {
1189
1348
  });
1190
1349
  persistRetainedRun(task);
1191
1350
  void persistRunTask(task).catch(() => undefined);
1351
+ publishImmediateRunChangedEvent({ nc: args.nc, userId: task.userId, task });
1192
1352
  writeRunStatus(task.id, `started requestId=${args.requestId} cwd=${prepared.taskWorkspace}`);
1353
+ if (!task.sessionId) {
1354
+ void pollPendingSession();
1355
+ }
1193
1356
  return cloneRunTask(task);
1194
1357
  }
1195
1358
  function shellSingleQuote(value) {
@@ -1202,12 +1365,22 @@ function normalizeCodexModel(value) {
1202
1365
  const normalized = typeof value === "string" ? value.trim() : "";
1203
1366
  return normalized || "gpt-5.4";
1204
1367
  }
1368
+ function toTomlStringLiteral(value) {
1369
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1370
+ }
1205
1371
  function buildManagedCodexArgs(args) {
1206
1372
  const promptArgs = ["--", args.prompt];
1207
1373
  const fixedArgs = ["--dangerously-bypass-approvals-and-sandbox"];
1374
+ const configArgs = [
1375
+ ...(args.personality ? ["--config", `personality=${toTomlStringLiteral(args.personality)}`] : []),
1376
+ ...(args.modelInstructionsFile
1377
+ ? ["--config", `model_instructions_file=${toTomlStringLiteral(args.modelInstructionsFile)}`]
1378
+ : []),
1379
+ ];
1208
1380
  const imageArgs = args.imagePaths.flatMap((imagePath) => ["--image", imagePath]);
1209
1381
  return [
1210
1382
  ...fixedArgs,
1383
+ ...configArgs,
1211
1384
  "--model",
1212
1385
  args.model,
1213
1386
  ...(args.sessionId
@@ -1578,6 +1751,14 @@ async function handleSettingsRpcMessage(args) {
1578
1751
  const next = request.action === "update" ? normalizeAgentSettingsConfig(request.patch, existing) : existing;
1579
1752
  if (request.action === "update") {
1580
1753
  await writeAgentSettingsConfig(next);
1754
+ const customInstructions = typeof request.patch.customInstructions === "string"
1755
+ ? request.patch.customInstructions
1756
+ : request.patch.customInstructions === null
1757
+ ? null
1758
+ : undefined;
1759
+ if (customInstructions !== undefined) {
1760
+ await writeAgentModelInstructions(customInstructions);
1761
+ }
1581
1762
  }
1582
1763
  else if (request.defaults) {
1583
1764
  const filePath = resolveAgentSettingsFilePath();
@@ -1592,7 +1773,7 @@ async function handleSettingsRpcMessage(args) {
1592
1773
  payload: {
1593
1774
  requestId,
1594
1775
  ok: true,
1595
- settings: toAgentSettingsPublic(next),
1776
+ settings: await toAgentSettingsPublic(next),
1596
1777
  },
1597
1778
  });
1598
1779
  }
@@ -2198,18 +2379,23 @@ async function handleRunRpcMessage(args) {
2198
2379
  const runId = request.runId ?? requestId;
2199
2380
  await claimRunStartSlot({ runId, sessionId: request.sessionId });
2200
2381
  try {
2382
+ const localAgentSettings = await readAgentSettingsConfig(null);
2383
+ const customInstructions = await readAgentModelInstructions();
2201
2384
  const task = await startManagedRun({
2202
2385
  requestId,
2203
2386
  runId,
2204
2387
  serverBaseUrl: args.serverBaseUrl,
2205
2388
  userId: args.userId,
2206
2389
  agentId: args.agentId,
2390
+ nc: args.jetstream.nc,
2207
2391
  sessionId: request.sessionId,
2208
2392
  codexArgs: buildManagedCodexArgs({
2209
2393
  prompt: request.prompt ?? "",
2210
2394
  imagePaths: request.imagePaths,
2211
2395
  sessionId: request.sessionId,
2212
2396
  model: request.model,
2397
+ personality: localAgentSettings.general.personality,
2398
+ modelInstructionsFile: customInstructions ? resolveAgentModelInstructionsFilePath() : null,
2213
2399
  }),
2214
2400
  cwd: request.cwd,
2215
2401
  runtimeEnvPatch: request.runtimeEnvPatch,
@@ -2253,6 +2439,7 @@ async function handleRunRpcMessage(args) {
2253
2439
  target.cancelRequested = true;
2254
2440
  target.updatedAt = formatLocalTimestamp();
2255
2441
  await persistRunTask(target);
2442
+ publishImmediateRunChangedEvent({ nc: args.jetstream.nc, userId: target.userId, task: target });
2256
2443
  writeRunStatus(target.id, `cancel requested pid=${target.processPid}`);
2257
2444
  sendSignalToPid(target.processPid, "SIGINT");
2258
2445
  const task = cloneRunTask(target);
@@ -3749,7 +3936,8 @@ function persistEventOrFatal(args) {
3749
3936
  }
3750
3937
  })();
3751
3938
  }
3752
- async function heartbeatAgent(args) {
3939
+ async function heartbeatAgentSession(args) {
3940
+ await args.nc.flush();
3753
3941
  await postJson(`${args.serverBaseUrl}/api/agent/heartbeat`, {
3754
3942
  userId: args.userId,
3755
3943
  agentToken: args.agentToken,
@@ -4189,23 +4377,45 @@ async function main() {
4189
4377
  else {
4190
4378
  writeAgentInfraError(`nats session restored agentId=${initialAgentId} servers=${jetstream.servers.join(",")} at=${formatLocalTimestamp()}`);
4191
4379
  }
4192
- let heartbeatHealthy = null;
4380
+ let heartbeatFailures = 0;
4381
+ let heartbeatInFlight = false;
4382
+ let sessionInvalidated = false;
4383
+ const invalidateAgentSession = (reason) => {
4384
+ if (sessionInvalidated) {
4385
+ return;
4386
+ }
4387
+ sessionInvalidated = true;
4388
+ writeAgentInfraError(`closing nats session: ${reason}`);
4389
+ void jetstream.nc.close().catch((error) => {
4390
+ const message = error instanceof Error ? error.message : String(error);
4391
+ writeAgentInfraError(`failed to close nats session: ${message}`);
4392
+ });
4393
+ };
4193
4394
  const heartbeatTimer = setInterval(() => {
4194
- void heartbeatAgent({ serverBaseUrl, userId, agentToken })
4395
+ if (heartbeatInFlight || sessionInvalidated) {
4396
+ return;
4397
+ }
4398
+ heartbeatInFlight = true;
4399
+ void heartbeatAgentSession({ nc: jetstream.nc, serverBaseUrl, userId, agentToken })
4195
4400
  .then(() => {
4196
- if (heartbeatHealthy === false) {
4401
+ heartbeatInFlight = false;
4402
+ if (heartbeatFailures > 0) {
4197
4403
  writeAgentInfraError(`heartbeat reconnected at=${formatLocalTimestamp()}`);
4198
4404
  }
4199
- heartbeatHealthy = true;
4405
+ heartbeatFailures = 0;
4200
4406
  })
4201
4407
  .catch((error) => {
4408
+ heartbeatInFlight = false;
4202
4409
  const message = error instanceof Error ? error.message : String(error);
4203
- if (heartbeatHealthy !== false) {
4204
- writeAgentInfraError(`heartbeat failed: ${message}`);
4410
+ heartbeatFailures += 1;
4411
+ if (heartbeatFailures > 1) {
4412
+ writeAgentInfraError(`heartbeat failed: ${message} (count=${heartbeatFailures}/${HEARTBEAT_FAILURE_THRESHOLD})`);
4413
+ }
4414
+ if (heartbeatFailures >= HEARTBEAT_FAILURE_THRESHOLD) {
4415
+ invalidateAgentSession(`heartbeat failure threshold reached at=${formatLocalTimestamp()}`);
4205
4416
  }
4206
- heartbeatHealthy = false;
4207
4417
  });
4208
- }, 10_000);
4418
+ }, HEARTBEAT_INTERVAL_MS);
4209
4419
  subscribeToFsRpc({
4210
4420
  jetstream,
4211
4421
  serverBaseUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",