automata-cli 0.1.0-feature-030-do-work.208 → 0.1.0-feature-030-do-work.212

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/index.js +299 -68
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -34,8 +34,9 @@ function writeDoWork(patch) {
34
34
  writeConfig({ ...current, doWork: { ...current.doWork, ...patch } });
35
35
  }
36
36
  function parseNonNegativeInt(value, label) {
37
- const parsed = Number.parseInt(value, 10);
38
- if (Number.isNaN(parsed) || parsed < 0 || String(parsed) !== value.trim()) {
37
+ const trimmed = value.trim();
38
+ const parsed = Number.parseInt(trimmed, 10);
39
+ if (Number.isNaN(parsed) || parsed < 0 || String(parsed) !== trimmed || !Number.isSafeInteger(parsed)) {
39
40
  process.stderr.write(`Error: ${label} must be a non-negative integer (got "${value}").
40
41
  `);
41
42
  process.exit(1);
@@ -722,7 +723,7 @@ function createTrackingBranch(branch) {
722
723
  return gitCommand(["checkout", "-b", branch, `origin/${branch}`]);
723
724
  }
724
725
  function fetchBranch(branch) {
725
- return gitCommand(["fetch", "origin", branch]);
726
+ return gitCommand(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
726
727
  }
727
728
  function pullFastForwardOnly(branch) {
728
729
  return gitCommand(branch === void 0 ? ["pull", "--ff-only"] : ["pull", "--ff-only", "origin", branch]);
@@ -2430,22 +2431,24 @@ query($owner:String!,$repo:String!,$cursor:String){
2430
2431
  pullRequests(states:OPEN, first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){
2431
2432
  pageInfo{ hasNextPage endCursor }
2432
2433
  nodes{
2433
- number url title headRefName isDraft updatedAt
2434
+ number url title headRefName baseRefName isCrossRepository isDraft updatedAt
2434
2435
  closingIssuesReferences(first:50){
2435
2436
  pageInfo{ hasNextPage }
2436
- nodes{ number }
2437
+ nodes{ number repository{ nameWithOwner } }
2437
2438
  }
2438
2439
  }
2439
2440
  }
2440
2441
  }
2441
2442
  }`.trim();
2442
2443
  var MAX_LINK_MAP_PAGES = 50;
2443
- function indexPullRequest(map, node) {
2444
+ function indexPullRequest(map, node, nameWithOwner) {
2444
2445
  const ref = {
2445
2446
  number: node.number,
2446
2447
  url: node.url,
2447
2448
  title: node.title,
2448
2449
  headRefName: node.headRefName,
2450
+ baseRefName: node.baseRefName,
2451
+ isCrossRepository: node.isCrossRepository,
2449
2452
  state: "OPEN",
2450
2453
  isDraft: node.isDraft,
2451
2454
  updatedAt: node.updatedAt
@@ -2456,6 +2459,7 @@ function indexPullRequest(map, node) {
2456
2459
  );
2457
2460
  }
2458
2461
  for (const issue of node.closingIssuesReferences.nodes) {
2462
+ if (issue.repository.nameWithOwner !== nameWithOwner) continue;
2459
2463
  const existing = map.get(issue.number);
2460
2464
  if (existing) {
2461
2465
  existing.push(ref);
@@ -2474,7 +2478,7 @@ function getOpenPrLinkMap() {
2474
2478
  const response = ghJson(args, "query open pull requests");
2475
2479
  const connection = response.data.repository.pullRequests;
2476
2480
  for (const node of connection.nodes) {
2477
- indexPullRequest(map, node);
2481
+ indexPullRequest(map, node, `${owner}/${repo}`);
2478
2482
  }
2479
2483
  if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
2480
2484
  return map;
@@ -2494,6 +2498,7 @@ query($owner:String!,$repo:String!,$prNumber:Int!,$cursor:String){
2494
2498
  nodes{
2495
2499
  isResolved isOutdated path line
2496
2500
  comments(last:100){
2501
+ pageInfo{ hasPreviousPage }
2497
2502
  nodes{ author{login} body createdAt }
2498
2503
  }
2499
2504
  }
@@ -2531,6 +2536,11 @@ function getReviewThreads(prNumber) {
2531
2536
  );
2532
2537
  const connection = response.data.repository.pullRequest.reviewThreads;
2533
2538
  for (const node of connection.nodes) {
2539
+ if (node.comments.pageInfo?.hasPreviousPage) {
2540
+ throw new Error(
2541
+ `Review thread on ${node.path} in pull request #${String(prNumber)} has more than 100 comments, so the earliest ones were not read. Refusing rather than risk suppressing a maintainer's request.`
2542
+ );
2543
+ }
2534
2544
  threads.push({
2535
2545
  path: node.path,
2536
2546
  line: node.line ?? null,
@@ -2559,7 +2569,7 @@ function getPrSurface(prNumber) {
2559
2569
  "view",
2560
2570
  String(prNumber),
2561
2571
  "--json",
2562
- "number,title,url,headRefName,state,isDraft,body,author,createdAt,updatedAt,comments,reviews"
2572
+ "number,title,url,headRefName,baseRefName,isCrossRepository,state,isDraft,body,author,createdAt,updatedAt,comments,reviews"
2563
2573
  ],
2564
2574
  `read pull request #${String(prNumber)}`
2565
2575
  );
@@ -2587,6 +2597,8 @@ function getPrSurface(prNumber) {
2587
2597
  url: raw.url,
2588
2598
  title: raw.title,
2589
2599
  headRefName: raw.headRefName,
2600
+ baseRefName: raw.baseRefName ?? "",
2601
+ isCrossRepository: raw.isCrossRepository ?? false,
2590
2602
  state,
2591
2603
  isDraft: raw.isDraft ?? false,
2592
2604
  updatedAt: raw.updatedAt ?? raw.createdAt
@@ -2712,7 +2724,24 @@ function decideWork(state, p, baseBranch) {
2712
2724
  };
2713
2725
  }
2714
2726
  const surface = prSurface;
2715
- const prAnalysis = analyzeSurface(surface.messages, p);
2727
+ if (surface.pr.isCrossRepository) {
2728
+ return {
2729
+ kind: "skip",
2730
+ issue,
2731
+ reason: "unsafe-pr-branch",
2732
+ detail: `pull request #${String(surface.pr.number)} comes from a fork; its head branch is not in this repository`
2733
+ };
2734
+ }
2735
+ if (surface.pr.headRefName === baseBranch) {
2736
+ return {
2737
+ kind: "skip",
2738
+ issue,
2739
+ reason: "unsafe-pr-branch",
2740
+ detail: `pull request #${String(surface.pr.number)} has the base branch (${baseBranch}) as its head`
2741
+ };
2742
+ }
2743
+ const agentThreadMessages = surface.threads.flatMap((thread) => thread.comments).filter((comment) => comment.author.toLowerCase() === p.agentUser.toLowerCase());
2744
+ const prAnalysis = analyzeSurface([...surface.messages, ...agentThreadMessages], p);
2716
2745
  const actionableThreads = findActionableThreads(surface.threads, p);
2717
2746
  const hasPrWork = prAnalysis.hasNewMessage || actionableThreads.length > 0;
2718
2747
  if (!hasPrWork && !issueAnalysis.hasNewMessage) {
@@ -2833,7 +2862,7 @@ function composePrompt(input) {
2833
2862
  }
2834
2863
 
2835
2864
  // src/run/runLock.ts
2836
- import { writeFileSync, readFileSync as readFileSync3, unlinkSync, mkdirSync, renameSync } from "fs";
2865
+ import { writeFileSync, readFileSync as readFileSync3, unlinkSync, mkdirSync, renameSync, linkSync } from "fs";
2837
2866
  import { randomUUID } from "crypto";
2838
2867
  import { hostname } from "os";
2839
2868
  import { join as join2 } from "path";
@@ -2843,6 +2872,16 @@ var RUN_LOCK_RELATIVE_PATH = `${LOCK_DIR}/${LOCK_FILE}`;
2843
2872
  function lockPath() {
2844
2873
  return join2(process.cwd(), LOCK_DIR, LOCK_FILE);
2845
2874
  }
2875
+ function processStartedAt(pid) {
2876
+ try {
2877
+ const stat = readFileSync3(`/proc/${String(pid)}/stat`, "utf8");
2878
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
2879
+ const field = afterComm.split(" ")[19];
2880
+ return field === void 0 || field.length === 0 ? null : field;
2881
+ } catch {
2882
+ return null;
2883
+ }
2884
+ }
2846
2885
  function isAlive(pid) {
2847
2886
  try {
2848
2887
  process.kill(pid, 0);
@@ -2860,7 +2899,8 @@ function readOwner(path) {
2860
2899
  startedAt: parsed.startedAt,
2861
2900
  host: parsed.host ?? "unknown",
2862
2901
  command: parsed.command ?? "unknown",
2863
- token: parsed.token ?? ""
2902
+ token: parsed.token ?? "",
2903
+ pidStartedAt: parsed.pidStartedAt
2864
2904
  };
2865
2905
  } catch {
2866
2906
  return null;
@@ -2869,12 +2909,24 @@ function readOwner(path) {
2869
2909
  function isStale(owner, staleMinutes) {
2870
2910
  if (owner === null) return true;
2871
2911
  if (owner.host === hostname()) {
2872
- return !isAlive(owner.pid);
2912
+ if (!isAlive(owner.pid)) return true;
2913
+ const startedAt2 = processStartedAt(owner.pid);
2914
+ if (owner.pidStartedAt !== void 0 && startedAt2 !== null && startedAt2 !== owner.pidStartedAt) {
2915
+ return true;
2916
+ }
2917
+ return false;
2873
2918
  }
2874
2919
  const startedAt = Date.parse(owner.startedAt);
2875
2920
  if (Number.isNaN(startedAt)) return true;
2876
2921
  return Date.now() - startedAt > staleMinutes * 60 * 1e3;
2877
2922
  }
2923
+ function heldTooLong(owner, staleMinutes) {
2924
+ if (owner.host !== hostname()) return false;
2925
+ if (owner.pidStartedAt !== void 0 && processStartedAt(owner.pid) !== null) return false;
2926
+ const startedAt = Date.parse(owner.startedAt);
2927
+ if (Number.isNaN(startedAt)) return true;
2928
+ return Date.now() - startedAt > staleMinutes * 60 * 1e3;
2929
+ }
2878
2930
  function makeHandle(path, token) {
2879
2931
  let released = false;
2880
2932
  return {
@@ -2885,8 +2937,26 @@ function makeHandle(path, token) {
2885
2937
  if (current !== null && current.token !== token) {
2886
2938
  return;
2887
2939
  }
2940
+ const takenAway = `${path}.releasing.${token}`;
2888
2941
  try {
2889
- unlinkSync(path);
2942
+ renameSync(path, takenAway);
2943
+ } catch {
2944
+ return;
2945
+ }
2946
+ const owner = readOwner(takenAway);
2947
+ if (owner === null || owner.token === token) {
2948
+ try {
2949
+ unlinkSync(takenAway);
2950
+ } catch {
2951
+ }
2952
+ return;
2953
+ }
2954
+ try {
2955
+ linkSync(takenAway, path);
2956
+ } catch {
2957
+ }
2958
+ try {
2959
+ unlinkSync(takenAway);
2890
2960
  } catch {
2891
2961
  }
2892
2962
  }
@@ -2898,7 +2968,8 @@ function write(path, command, token, flag = "wx") {
2898
2968
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2899
2969
  host: hostname(),
2900
2970
  command,
2901
- token
2971
+ token,
2972
+ pidStartedAt: processStartedAt(process.pid) ?? void 0
2902
2973
  };
2903
2974
  writeFileSync(path, JSON.stringify(owner, null, 2) + "\n", { encoding: "utf8", flag });
2904
2975
  }
@@ -2914,9 +2985,10 @@ function acquireRunLock(command, staleMinutes) {
2914
2985
  }
2915
2986
  const owner = readOwner(path);
2916
2987
  if (!isStale(owner, staleMinutes)) {
2917
- return { ok: false, heldBy: owner };
2988
+ const held = owner;
2989
+ return { ok: false, heldBy: held, suspect: heldTooLong(held, staleMinutes) };
2918
2990
  }
2919
- return reclaim(path, command, token);
2991
+ return reclaim(path, command, token, owner);
2920
2992
  }
2921
2993
  var UNKNOWN_OWNER = {
2922
2994
  pid: 0,
@@ -2925,18 +2997,34 @@ var UNKNOWN_OWNER = {
2925
2997
  command: "unknown",
2926
2998
  token: ""
2927
2999
  };
2928
- function claimStaleLock(path, token) {
3000
+ function claimStaleLock(path, token, expected) {
3001
+ const claimed = `${path}.stale.${token}`;
2929
3002
  try {
2930
- renameSync(path, `${path}.stale.${token}`);
2931
- return true;
3003
+ renameSync(path, claimed);
2932
3004
  } catch (err) {
2933
3005
  if (err.code === "ENOENT") return false;
2934
3006
  throw err;
2935
3007
  }
3008
+ if (expected !== void 0) {
3009
+ const taken = readOwner(claimed);
3010
+ const sameFile = expected === null && taken === null || expected !== null && taken !== null && taken.token === expected.token;
3011
+ if (!sameFile) {
3012
+ try {
3013
+ linkSync(claimed, path);
3014
+ } catch {
3015
+ }
3016
+ try {
3017
+ unlinkSync(claimed);
3018
+ } catch {
3019
+ }
3020
+ return false;
3021
+ }
3022
+ }
3023
+ return true;
2936
3024
  }
2937
- function reclaim(path, command, token) {
2938
- if (!claimStaleLock(path, token)) {
2939
- return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER };
3025
+ function reclaim(path, command, token, expected) {
3026
+ if (!claimStaleLock(path, token, expected)) {
3027
+ return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
2940
3028
  }
2941
3029
  const claimed = `${path}.stale.${token}`;
2942
3030
  try {
@@ -2944,7 +3032,7 @@ function reclaim(path, command, token) {
2944
3032
  return { ok: true, handle: makeHandle(path, token) };
2945
3033
  } catch (err) {
2946
3034
  if (err.code !== "EEXIST") throw err;
2947
- return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER };
3035
+ return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
2948
3036
  } finally {
2949
3037
  try {
2950
3038
  unlinkSync(claimed);
@@ -2995,6 +3083,7 @@ function preparePrBranch(headRefName) {
2995
3083
  }
2996
3084
 
2997
3085
  // src/commands/doWork.ts
3086
+ var inFlightMarker = null;
2998
3087
  function out(message) {
2999
3088
  process.stdout.write(message);
3000
3089
  }
@@ -3043,8 +3132,8 @@ function resolveSettings(options) {
3043
3132
  if (agentUser.length === 0) {
3044
3133
  fail("No agent user configured. Run `automata config set agent-user <login>`.");
3045
3134
  }
3135
+ validateDoWorkConfig(config.doWork);
3046
3136
  const doWork = config.doWork ?? {};
3047
- validateDoWorkConfig(doWork);
3048
3137
  let executor = doWork.executor ?? DEFAULT_DO_WORK.executor;
3049
3138
  if (options.with !== void 0) {
3050
3139
  const requested = options.with.toLowerCase();
@@ -3136,33 +3225,49 @@ function describePlannedRun(item, settings, run5) {
3136
3225
  ];
3137
3226
  return lines.join("\n") + "\n";
3138
3227
  }
3139
- function validateDoWorkConfig(doWork) {
3140
- if (doWork.executor !== void 0 && doWork.executor !== "claude" && doWork.executor !== "codex") {
3141
- fail(`doWork.executor must be 'claude' or 'codex', got '${String(doWork.executor)}'.`);
3228
+ function isPlainObject(value) {
3229
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3230
+ }
3231
+ function validateOptionalString(container, key, path) {
3232
+ const value = container[key];
3233
+ if (value === void 0 || value === null) return;
3234
+ if (typeof value !== "string" || value.trim().length === 0) {
3235
+ fail(`${path} must be a non-empty string.`);
3142
3236
  }
3143
- if (doWork.baseBranch?.trim().length === 0) {
3144
- fail("doWork.baseBranch must not be empty.");
3237
+ }
3238
+ function validateOptionalInt(container, key, path, min, hint) {
3239
+ const value = container[key];
3240
+ if (value === void 0 || value === null) return;
3241
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
3242
+ fail(`${path} must be ${hint}, got ${JSON.stringify(value)}.`);
3145
3243
  }
3146
- if (doWork.maxRunsPerTick !== void 0) {
3147
- if (!Number.isSafeInteger(doWork.maxRunsPerTick) || doWork.maxRunsPerTick < 0) {
3148
- fail(
3149
- `doWork.maxRunsPerTick must be a non-negative integer (0 = unlimited), got ${String(doWork.maxRunsPerTick)}.`
3150
- );
3151
- }
3244
+ }
3245
+ function validateDoWorkConfig(section) {
3246
+ if (section === void 0 || section === null) return;
3247
+ if (!isPlainObject(section)) {
3248
+ fail(`doWork must be an object, got ${JSON.stringify(section)}.`);
3152
3249
  }
3153
- if (doWork.lockStaleMinutes !== void 0) {
3154
- if (!Number.isSafeInteger(doWork.lockStaleMinutes) || doWork.lockStaleMinutes <= 0) {
3155
- fail(`doWork.lockStaleMinutes must be a positive integer, got ${String(doWork.lockStaleMinutes)}.`);
3156
- }
3250
+ const executor = section["executor"];
3251
+ if (executor !== void 0 && executor !== "claude" && executor !== "codex") {
3252
+ fail(`doWork.executor must be 'claude' or 'codex', got ${JSON.stringify(executor)}.`);
3157
3253
  }
3158
- for (const [key, value] of [
3159
- ["doWork.models.claude", doWork.models?.claude],
3160
- ["doWork.models.codex", doWork.models?.codex],
3161
- ["doWork.prompts.issueDiscuss", doWork.prompts?.issueDiscuss],
3162
- ["doWork.prompts.prWork", doWork.prompts?.prWork]
3163
- ]) {
3164
- if (value !== void 0 && (typeof value !== "string" || value.trim().length === 0)) {
3165
- fail(`${key} must be a non-empty string.`);
3254
+ validateOptionalString(section, "baseBranch", "doWork.baseBranch");
3255
+ validateOptionalInt(section, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
3256
+ validateOptionalInt(section, "lockStaleMinutes", "doWork.lockStaleMinutes", 1, "a positive integer");
3257
+ for (const container of ["models", "prompts"]) {
3258
+ const value = section[container];
3259
+ if (value === void 0 || value === null) continue;
3260
+ if (!isPlainObject(value)) {
3261
+ fail(`doWork.${container} must be an object, got ${JSON.stringify(value)}.`);
3262
+ }
3263
+ const keys = container === "models" ? ["claude", "codex"] : ["issueDiscuss", "prWork"];
3264
+ for (const key of keys) {
3265
+ validateOptionalString(value, key, `doWork.${container}.${key}`);
3266
+ }
3267
+ for (const key of Object.keys(value)) {
3268
+ if (!keys.includes(key)) {
3269
+ fail(`doWork.${container}.${key} is not a recognised setting; expected one of: ${keys.join(", ")}.`);
3270
+ }
3166
3271
  }
3167
3272
  }
3168
3273
  }
@@ -3220,6 +3325,40 @@ function describePlan(decisions) {
3220
3325
  });
3221
3326
  return lines.length === 0 ? " (no issues matched the discovery filter)\n" : lines.join("\n") + "\n";
3222
3327
  }
3328
+ function claimIssue(item, settings) {
3329
+ if (!item.needsAssignment) return;
3330
+ try {
3331
+ assignIssueToAgent(item.issue.number, settings.participants.agentUser);
3332
+ progress(` assigned issue #${String(item.issue.number)} to ${settings.participants.agentUser}.
3333
+ `);
3334
+ } catch (err) {
3335
+ progress(` warning: could not assign issue #${String(item.issue.number)}: ${err.message}
3336
+ `);
3337
+ }
3338
+ }
3339
+ function notePickupOnIssue(item, marker) {
3340
+ if (item.turn !== "pr-work" || item.pr === null || !item.issueAnalysis.hasNewMessage) return true;
3341
+ try {
3342
+ postMarker(
3343
+ "issue",
3344
+ item.issue.number,
3345
+ `automata do-work: picked this up on pull request #${String(item.pr.number)} \u2014 ${item.pr.url}`
3346
+ );
3347
+ progress(` noted on issue #${String(item.issue.number)} that the work is on pull request #${String(item.pr.number)}.
3348
+ `);
3349
+ return true;
3350
+ } catch (err) {
3351
+ progress(` skipped: could not note the pickup on issue #${String(item.issue.number)} \u2014 ${err.message}
3352
+ `);
3353
+ try {
3354
+ deleteMarker(marker);
3355
+ } catch (deleteErr) {
3356
+ progress(` warning: could not withdraw the working marker: ${deleteErr.message}
3357
+ `);
3358
+ }
3359
+ return false;
3360
+ }
3361
+ }
3223
3362
  function refreshItem(item, settings) {
3224
3363
  const linkMap = getOpenPrLinkMap();
3225
3364
  return decideWork(
@@ -3228,6 +3367,14 @@ function refreshItem(item, settings) {
3228
3367
  settings.baseBranch
3229
3368
  );
3230
3369
  }
3370
+ function authorizedMessagesSince(messages, participants, marker) {
3371
+ const allowed = participants.allowedUsers.map((user) => user.toLowerCase());
3372
+ const agent = participants.agentUser.toLowerCase();
3373
+ return messages.filter((message) => {
3374
+ const author = message.author.toLowerCase();
3375
+ return author !== agent && allowed.includes(author) && message.createdAt > marker.createdAt;
3376
+ });
3377
+ }
3231
3378
  function readAnsweringSurface(item) {
3232
3379
  if (item.turn === "issue-discuss" || item.pr === null) {
3233
3380
  return getIssueSurface(item.issue.number).messages;
@@ -3238,10 +3385,13 @@ function readAnsweringSurface(item) {
3238
3385
  function markerSurfaceLabel(item) {
3239
3386
  return item.turn === "pr-work" && item.pr ? `pull request #${String(item.pr.number)}` : `issue #${String(item.issue.number)}`;
3240
3387
  }
3241
- function reconcileMarker(item, marker, agentUser, runError) {
3388
+ function reconcileMarker(item, marker, participants, runError) {
3389
+ const agentUser = participants.agentUser;
3390
+ let surfaceMessages = [];
3242
3391
  let answered;
3243
3392
  try {
3244
- answered = agentAnsweredAfter(readAnsweringSurface(item), agentUser, marker);
3393
+ surfaceMessages = readAnsweringSurface(item);
3394
+ answered = agentAnsweredAfter(surfaceMessages, agentUser, marker);
3245
3395
  } catch (err) {
3246
3396
  progress(
3247
3397
  ` warning: could not re-read issue #${String(item.issue.number)} to check for an answer: ${err.message}
@@ -3263,12 +3413,30 @@ function reconcileMarker(item, marker, agentUser, runError) {
3263
3413
  return { outcome: "answered-no-reply", detail: "could not verify whether an answer was posted" };
3264
3414
  }
3265
3415
  if (answered) {
3416
+ const interleaved = authorizedMessagesSince(surfaceMessages, participants, marker);
3417
+ if (interleaved.length > 0) {
3418
+ const authors = [...new Set(interleaved.map((message) => message.author))].join(", ");
3419
+ try {
3420
+ postMarker(
3421
+ item.turn === "pr-work" ? "pr" : "issue",
3422
+ item.turn === "pr-work" && item.pr ? item.pr.number : item.issue.number,
3423
+ `automata do-work: ${authors} posted here while this run was already in progress, so ${interleaved.length === 1 ? "that message was" : "those messages were"} not included in it. Please post again to have them acted on.`
3424
+ );
3425
+ } catch (err) {
3426
+ progress(` warning: could not report the interleaved message(s): ${err.message}
3427
+ `);
3428
+ }
3429
+ }
3266
3430
  try {
3267
3431
  deleteMarker(marker);
3268
3432
  } catch (err) {
3269
3433
  progress(` warning: could not delete the marker comment: ${err.message}
3270
3434
  `);
3271
3435
  }
3436
+ const noted = interleaved.length > 0 ? ` (${String(interleaved.length)} message(s) arrived mid-run and were flagged)` : "";
3437
+ if (interleaved.length > 0) {
3438
+ return { outcome: "answered-no-reply", detail: `answered${noted}` };
3439
+ }
3272
3440
  return runError === null ? { outcome: "answered", detail: "answered" } : { outcome: "answered", detail: `answered, but the run reported: ${runError.message}` };
3273
3441
  }
3274
3442
  const surface = markerSurfaceLabel(item);
@@ -3305,7 +3473,8 @@ function repairIssueLink(item, baseBranch) {
3305
3473
  `);
3306
3474
  return;
3307
3475
  }
3308
- if (pr.body.includes(`Closes #${String(item.issue.number)}`)) {
3476
+ const closesRef = new RegExp(String.raw`\bcloses\s+#` + String(item.issue.number) + String.raw`\b`, "i");
3477
+ if (closesRef.test(pr.body)) {
3309
3478
  progress(` pull request #${String(pr.number)} already closes issue #${String(item.issue.number)}.
3310
3479
  `);
3311
3480
  return;
@@ -3350,16 +3519,7 @@ async function processItem(planned, settings, silent) {
3350
3519
  `);
3351
3520
  return { ...base, outcome: "skipped", detail: `${prepared.reason}: ${prepared.detail}` };
3352
3521
  }
3353
- if (item.needsAssignment) {
3354
- try {
3355
- assignIssueToAgent(item.issue.number, settings.participants.agentUser);
3356
- progress(` assigned issue #${String(item.issue.number)} to ${settings.participants.agentUser}.
3357
- `);
3358
- } catch (err) {
3359
- progress(` warning: could not assign issue #${String(item.issue.number)}: ${err.message}
3360
- `);
3361
- }
3362
- }
3522
+ claimIssue(item, settings);
3363
3523
  let marker;
3364
3524
  const markerSurface = item.turn === "pr-work" && item.pr ? item.pr.number : item.issue.number;
3365
3525
  try {
@@ -3369,6 +3529,11 @@ async function processItem(planned, settings, silent) {
3369
3529
  `);
3370
3530
  return { ...base, outcome: "skipped", detail: `marker failed: ${err.message}` };
3371
3531
  }
3532
+ inFlightMarker = { marker, item };
3533
+ if (!notePickupOnIssue(item, marker)) {
3534
+ inFlightMarker = null;
3535
+ return { ...base, outcome: "skipped", detail: "issue pickup note failed" };
3536
+ }
3372
3537
  const prompt = composePrompt({
3373
3538
  item,
3374
3539
  repo: getRepoSlug(),
@@ -3376,19 +3541,39 @@ async function processItem(planned, settings, silent) {
3376
3541
  baseBranch: settings.baseBranch,
3377
3542
  frame: settings.prompts[item.turn]
3378
3543
  });
3544
+ const MAX_PROMPT_BYTES = 96 * 1024;
3545
+ const promptBytes = Buffer.byteLength(prompt, "utf8");
3546
+ if (promptBytes > MAX_PROMPT_BYTES) {
3547
+ const detail = `the composed prompt is ${String(Math.round(promptBytes / 1024))} KiB, over the ${String(MAX_PROMPT_BYTES / 1024)} KiB limit for a single command-line argument. The conversation is too long to hand to the executor this way.`;
3548
+ progress(` failed: ${detail}
3549
+ `);
3550
+ inFlightMarker = null;
3551
+ try {
3552
+ updateMarker(
3553
+ marker,
3554
+ `automata do-work: could not start a run because ${detail} Summarise the discussion in a new issue, or shorten the thread, and try again.`
3555
+ );
3556
+ } catch (err) {
3557
+ progress(` warning: could not update the marker comment: ${err.message}
3558
+ `);
3559
+ }
3560
+ return { ...base, outcome: "failed", detail };
3561
+ }
3379
3562
  let runError = null;
3380
3563
  try {
3381
3564
  await invokeExecutor(prompt, settings, silent);
3382
3565
  } catch (err) {
3383
3566
  runError = err;
3384
3567
  }
3385
- const reconciled = reconcileMarker(item, marker, settings.participants.agentUser, runError);
3568
+ const ranExecutor = true;
3569
+ inFlightMarker = null;
3570
+ const reconciled = reconcileMarker(item, marker, settings.participants, runError);
3386
3571
  progress(` ${reconciled.detail}
3387
3572
  `);
3388
3573
  if (item.turn === "issue-discuss") {
3389
3574
  repairIssueLink(item, settings.baseBranch);
3390
3575
  }
3391
- return { ...base, outcome: reconciled.outcome, detail: reconciled.detail };
3576
+ return { ...base, outcome: reconciled.outcome, detail: reconciled.detail, ranExecutor };
3392
3577
  }
3393
3578
  function summarize(reports) {
3394
3579
  out("\nTick summary:\n");
@@ -3408,12 +3593,33 @@ var doWorkCommand = new Command6("do-work").description(
3408
3593
  "Print the work plan, plus a summary and the exact command that would be launched for each item, and exit without changing anything"
3409
3594
  ).option("--json", "Emit the work plan and outcomes as JSON on stdout").option("--silent", "Suppress step-by-step Claude output; show only the final summary").action(async (options) => {
3410
3595
  const settings = resolveSettings(options);
3596
+ if (options.dryRun === true) {
3597
+ const exitCode2 = await runTick(settings, options);
3598
+ if (exitCode2 !== 0) process.exit(exitCode2);
3599
+ return;
3600
+ }
3411
3601
  const lock = acquireRunLock("do-work", settings.lockStaleMinutes);
3412
3602
  if (!lock.ok) {
3413
- out(
3414
- `Another automata instance is already running here (pid ${String(lock.heldBy.pid)} on ${lock.heldBy.host}, started ${lock.heldBy.startedAt}, command ${lock.heldBy.command}). Doing nothing.
3415
- `
3416
- );
3603
+ const held = lock.heldBy;
3604
+ const sentence = `Another automata instance is already running here (pid ${String(held.pid)} on ${held.host}, started ${held.startedAt}, command ${held.command}). Doing nothing.
3605
+ `;
3606
+ const suspectSentence = lock.suspect ? `Warning: that lock has been held longer than ${String(settings.lockStaleMinutes)} minutes. If no tick is really running, its process id was probably reused; remove ${RUN_LOCK_RELATIVE_PATH} once you have confirmed that.
3607
+ ` : "";
3608
+ const exitCode2 = lock.suspect ? 2 : 0;
3609
+ if (options.json === true) {
3610
+ progress(sentence + suspectSentence);
3611
+ out(
3612
+ JSON.stringify(
3613
+ { lockHeld: true, suspect: lock.suspect, heldBy: held, plan: [], items: [], exitCode: exitCode2 },
3614
+ null,
3615
+ 2
3616
+ ) + "\n"
3617
+ );
3618
+ } else {
3619
+ out(sentence);
3620
+ if (suspectSentence) progress(suspectSentence);
3621
+ }
3622
+ if (exitCode2 !== 0) process.exit(exitCode2);
3417
3623
  return;
3418
3624
  }
3419
3625
  const handle = lock.handle;
@@ -3422,6 +3628,18 @@ var doWorkCommand = new Command6("do-work").description(
3422
3628
  if (shuttingDown) return;
3423
3629
  shuttingDown = true;
3424
3630
  progress("\nInterrupted: stopping the executor before releasing the run lock\u2026\n");
3631
+ const pending = inFlightMarker;
3632
+ if (pending !== null) {
3633
+ try {
3634
+ updateMarker(
3635
+ pending.marker,
3636
+ `automata do-work: this run was interrupted before it finished, so no answer was produced. The branch \`${pending.item.branch}\` may have been changed. Reply here to have another attempt made.`
3637
+ );
3638
+ } catch (err) {
3639
+ progress(`Warning: could not update the in-flight marker: ${err.message}
3640
+ `);
3641
+ }
3642
+ }
3425
3643
  void terminateTrackedChildren().then((allExited) => {
3426
3644
  if (allExited) {
3427
3645
  handle.release();
@@ -3473,8 +3691,21 @@ ${describePlan(decisions)}`;
3473
3691
  deferred.push(item);
3474
3692
  continue;
3475
3693
  }
3476
- const report = await processItem(item, settings, options.silent === true);
3477
- if (report.outcome !== "skipped") runsUsed++;
3694
+ let report;
3695
+ try {
3696
+ report = await processItem(item, settings, options.silent === true);
3697
+ } catch (err) {
3698
+ progress(` failed: ${err.message}
3699
+ `);
3700
+ report = {
3701
+ issue: item.issue.number,
3702
+ title: item.issue.title,
3703
+ turn: item.turn,
3704
+ outcome: "failed",
3705
+ detail: err.message
3706
+ };
3707
+ }
3708
+ if (report.ranExecutor === true) runsUsed++;
3478
3709
  reports.push(report);
3479
3710
  }
3480
3711
  for (const item of deferred) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "automata-cli",
3
- "version": "0.1.0-feature-030-do-work.208",
3
+ "version": "0.1.0-feature-030-do-work.212",
4
4
  "description": "Automata CLI tool",
5
5
  "type": "module",
6
6
  "bin": {