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

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.
@@ -9,7 +9,7 @@ import {
9
9
  readConfig,
10
10
  readRawConfig,
11
11
  writeConfig
12
- } from "./chunk-UA32XYYL.js";
12
+ } from "./chunk-LTCVLM4I.js";
13
13
 
14
14
  // src/config/ConfigWizard.tsx
15
15
  import { useState } from "react";
@@ -9,11 +9,12 @@ var DEFAULT_SONAR_PROMPT = "You are an expert software engineer. You have been g
9
9
  var DEFAULT_CHECK_ISSUE_PROMPT = "You are an expert software engineer working on a GitHub issue. Below is the conversation on that issue, restricted to the people allowed to instruct you and your own previous replies. Messages marked as new arrived after your last run: treat them as the current instruction and read the earlier messages only as context. Do what the new messages ask, following the project's existing conventions and style, and make minimal, targeted changes. Run tests and linting before finishing, then reply on the issue with a short summary of what you did.";
10
10
  var DEFAULT_DO_WORK = {
11
11
  baseBranch: "develop",
12
+ protectedBranches: ["main", "master"],
12
13
  executor: "claude",
13
14
  maxRunsPerTick: 0,
14
15
  lockStaleMinutes: 120
15
16
  };
16
- var DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT = "You are the agent named in the context below, working on a GitHub issue together with the people allowed to instruct you. Answer the messages marked NEW; the earlier messages are context only.\n\nDo not modify, create or delete any file, and do not create a branch or a pull request, UNLESS a message marked NEW explicitly asks you to implement the work. If it does: create a branch off the base branch named below, implement the change following the project's existing conventions, run the tests and the linter, and open a pull request whose body contains `Closes #<issue number>`.\n\nOtherwise do not touch the code at all: reply on the issue with the specification, the plan, or the open questions you need answered. Keep the reply short and concrete, and always post a reply \u2014 silence looks like a crash.";
17
+ var DEFAULT_DO_WORK_ISSUE_DISCUSS_PROMPT = "You are the agent named in the context below, working on a GitHub issue together with the people allowed to instruct you. Answer the messages marked NEW; the earlier messages are context only.\n\nDo not modify, create or delete any file, and do not create a branch or a pull request, UNLESS a message marked NEW explicitly asks you to implement the work. If it does: create a branch off the base branch named below, implement the change following the project's existing conventions, run the tests and the linter, and open a pull request whose body contains `Closes #<issue number>`.\n\nOtherwise do not touch the code at all: reply on the issue with the specification, the plan, or the open questions you need answered. Keep the reply short and concrete.\n\nEither way, always post a reply on the issue before you finish \u2014 including when you implemented and opened a pull request. Silence is indistinguishable from a crash, and the run will be reported as having produced no answer.";
17
18
  var DEFAULT_DO_WORK_PR_WORK_PROMPT = "You are the agent named in the context below, working on the pull request for a GitHub issue together with the people allowed to instruct you. Work on the branch named below, which is already checked out and up to date.\n\nAddress every message marked NEW and every unresolved review thread listed. Follow the project's existing conventions, run the tests and the linter, then commit and push to that branch. Do not merge the pull request and do not push to the base branch.\n\nReply on the pull request with a short summary of what you changed, or reply in the review thread when your answer belongs to a specific comment. Always post a reply \u2014 silence looks like a crash.";
18
19
  var CONFIG_DIR = ".automata";
19
20
  var CONFIG_FILE = "config.json";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  readConfig,
11
11
  readRawConfig,
12
12
  writeConfig
13
- } from "./chunk-UA32XYYL.js";
13
+ } from "./chunk-LTCVLM4I.js";
14
14
 
15
15
  // src/index.ts
16
16
  import { Command as Command7 } from "commander";
@@ -184,7 +184,7 @@ var configCommand = new Command("config").description("Configure automata settin
184
184
  const [{ render }, React, { ConfigWizard }] = await Promise.all([
185
185
  import("ink"),
186
186
  import("react"),
187
- import("./ConfigWizard-OYNUMRTO.js")
187
+ import("./ConfigWizard-O25ZZT5R.js")
188
188
  ]);
189
189
  const { waitUntilExit } = render(React.createElement(ConfigWizard));
190
190
  await waitUntilExit();
@@ -2350,6 +2350,10 @@ function login(author) {
2350
2350
  function byCreatedAt2(a, b) {
2351
2351
  return a.createdAt.localeCompare(b.createdAt);
2352
2352
  }
2353
+ function visibleAt(createdAt, submittedAt) {
2354
+ if (submittedAt === null || submittedAt === void 0) return createdAt;
2355
+ return submittedAt > createdAt ? submittedAt : createdAt;
2356
+ }
2353
2357
  function getRepoSlug() {
2354
2358
  const { stdout, status } = run4("git", ["remote", "get-url", "origin"]);
2355
2359
  if (status !== 0) {
@@ -2428,6 +2432,7 @@ function getIssueSurface(issueNumber) {
2428
2432
  var LINK_MAP_QUERY = `
2429
2433
  query($owner:String!,$repo:String!,$cursor:String){
2430
2434
  repository(owner:$owner,name:$repo){
2435
+ defaultBranchRef{ name }
2431
2436
  pullRequests(states:OPEN, first:100, after:$cursor, orderBy:{field:UPDATED_AT, direction:DESC}){
2432
2437
  pageInfo{ hasNextPage endCursor }
2433
2438
  nodes{
@@ -2471,17 +2476,19 @@ function indexPullRequest(map, node, nameWithOwner) {
2471
2476
  function getOpenPrLinkMap() {
2472
2477
  const { owner, repo } = getRepoSlug();
2473
2478
  const map = /* @__PURE__ */ new Map();
2479
+ let defaultBranch = null;
2474
2480
  let cursor = null;
2475
2481
  for (let page = 0; page < MAX_LINK_MAP_PAGES; page++) {
2476
2482
  const args = ["api", "graphql", "-f", `query=${LINK_MAP_QUERY}`, "-f", `owner=${owner}`, "-f", `repo=${repo}`];
2477
2483
  if (cursor !== null) args.push("-f", `cursor=${cursor}`);
2478
2484
  const response = ghJson(args, "query open pull requests");
2485
+ defaultBranch = response.data.repository.defaultBranchRef?.name ?? defaultBranch;
2479
2486
  const connection = response.data.repository.pullRequests;
2480
2487
  for (const node of connection.nodes) {
2481
2488
  indexPullRequest(map, node, `${owner}/${repo}`);
2482
2489
  }
2483
2490
  if (!connection.pageInfo?.hasNextPage || connection.pageInfo.endCursor === null) {
2484
- return map;
2491
+ return { byIssue: map, defaultBranch };
2485
2492
  }
2486
2493
  cursor = connection.pageInfo.endCursor;
2487
2494
  }
@@ -2499,7 +2506,7 @@ query($owner:String!,$repo:String!,$prNumber:Int!,$cursor:String){
2499
2506
  isResolved isOutdated path line
2500
2507
  comments(last:100){
2501
2508
  pageInfo{ hasPreviousPage }
2502
- nodes{ author{login} body createdAt }
2509
+ nodes{ author{login} body createdAt url pullRequestReview{ submittedAt } }
2503
2510
  }
2504
2511
  }
2505
2512
  }
@@ -2545,11 +2552,19 @@ function getReviewThreads(prNumber) {
2545
2552
  path: node.path,
2546
2553
  line: node.line ?? null,
2547
2554
  isResolved: node.isResolved,
2555
+ url: node.comments.nodes.at(-1)?.url ?? null,
2548
2556
  comments: node.comments.nodes.map((comment) => ({
2549
2557
  kind: "thread-comment",
2550
2558
  author: login(comment.author),
2551
2559
  body: comment.body,
2552
- createdAt: comment.createdAt
2560
+ // When the comment became *visible*, not when it was drafted.
2561
+ // GitHub stamps `createdAt` the moment a comment is added to a
2562
+ // pending review, and it only becomes visible when the review is
2563
+ // submitted — minutes later for a human working through a diff.
2564
+ // Using `createdAt` let an agent answer posted in between look newer
2565
+ // than the review, which marked every one of its threads answered
2566
+ // and discarded the whole review silently.
2567
+ createdAt: visibleAt(comment.createdAt, comment.pullRequestReview?.submittedAt)
2553
2568
  })).sort(byCreatedAt2)
2554
2569
  });
2555
2570
  }
@@ -2667,12 +2682,16 @@ function isAssignedToAgent(assignees, agentUser) {
2667
2682
  const agent = agentUser.toLowerCase();
2668
2683
  return assignees.some((name) => name.toLowerCase() === agent);
2669
2684
  }
2670
- function findActionableThreads(threads, p) {
2685
+ function findActionableThreads(threads, p, prLastAgentAt) {
2671
2686
  const actionable = [];
2672
2687
  for (const thread of threads) {
2673
2688
  if (thread.isResolved) continue;
2674
2689
  const comments = thread.comments.filter((comment) => classifyForThread(comment.author, p) !== "other");
2675
2690
  if (lastAuthorClass(comments, p) !== "authorized") continue;
2691
+ const newestAuthorized = comments.at(-1)?.createdAt;
2692
+ if (newestAuthorized !== void 0 && prLastAgentAt !== null && prLastAgentAt > newestAuthorized) {
2693
+ continue;
2694
+ }
2676
2695
  actionable.push({ ...thread, comments });
2677
2696
  }
2678
2697
  return actionable;
@@ -2688,7 +2707,32 @@ function newestPr(prs) {
2688
2707
  function plural(count, noun) {
2689
2708
  return `${String(count)} ${noun}${count === 1 ? "" : "s"}`;
2690
2709
  }
2691
- function decideWork(state, p, baseBranch) {
2710
+ function protectedHeads(policy) {
2711
+ return [policy.baseBranch, policy.defaultBranch ?? "", ...policy.protectedBranches ?? []].filter(
2712
+ (branch) => branch.length > 0
2713
+ );
2714
+ }
2715
+ function unsafeBranchSkip(pr, issue, policy) {
2716
+ if (pr.isCrossRepository) {
2717
+ return {
2718
+ kind: "skip",
2719
+ issue,
2720
+ reason: "unsafe-pr-branch",
2721
+ detail: `pull request #${String(pr.number)} comes from a fork; its head branch is not in this repository`
2722
+ };
2723
+ }
2724
+ if (protectedHeads(policy).includes(pr.headRefName)) {
2725
+ return {
2726
+ kind: "skip",
2727
+ issue,
2728
+ reason: "unsafe-pr-branch",
2729
+ detail: `pull request #${String(pr.number)} has a protected branch (${pr.headRefName}) as its head, so a turn would have to push to it`
2730
+ };
2731
+ }
2732
+ return null;
2733
+ }
2734
+ function decideWork(state, p, policy) {
2735
+ const baseBranch = policy.baseBranch;
2692
2736
  const { issueSurface, prSurface } = state;
2693
2737
  const issue = issueSurface.issue;
2694
2738
  if (issueSurface.state === "CLOSED") {
@@ -2724,25 +2768,11 @@ function decideWork(state, p, baseBranch) {
2724
2768
  };
2725
2769
  }
2726
2770
  const surface = prSurface;
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
- }
2771
+ const unsafe = unsafeBranchSkip(surface.pr, issue, policy);
2772
+ if (unsafe !== null) return unsafe;
2743
2773
  const agentThreadMessages = surface.threads.flatMap((thread) => thread.comments).filter((comment) => comment.author.toLowerCase() === p.agentUser.toLowerCase());
2744
2774
  const prAnalysis = analyzeSurface([...surface.messages, ...agentThreadMessages], p);
2745
- const actionableThreads = findActionableThreads(surface.threads, p);
2775
+ const actionableThreads = findActionableThreads(surface.threads, p, prAnalysis.lastAgentAt);
2746
2776
  const hasPrWork = prAnalysis.hasNewMessage || actionableThreads.length > 0;
2747
2777
  if (!hasPrWork && !issueAnalysis.hasNewMessage) {
2748
2778
  return {
@@ -2784,12 +2814,6 @@ function selectLinkedPr(linkedPrs) {
2784
2814
  const open = linkedPrs.filter((pr) => pr.state === "OPEN");
2785
2815
  return open.length === 0 ? null : newestPr(open);
2786
2816
  }
2787
- function agentAnsweredAfter(messages, agentUser, marker) {
2788
- const agent = agentUser.toLowerCase();
2789
- return messages.some(
2790
- (message) => message.author.toLowerCase() === agent && message.kind !== "issue-body" && message.createdAt > marker.createdAt
2791
- );
2792
- }
2793
2817
 
2794
2818
  // src/github/workPrompt.ts
2795
2819
  function formatThreads(threads) {
@@ -2798,7 +2822,9 @@ function formatThreads(threads) {
2798
2822
  const newest = thread.comments.at(-1);
2799
2823
  const author = newest?.author ?? "unknown";
2800
2824
  const body = newest?.body ?? "";
2801
- return `[${author}] ${location}
2825
+ const link = thread.url === null ? "" : `
2826
+ ${thread.url}`;
2827
+ return `[${author}] ${location}${link}
2802
2828
  ${body}`;
2803
2829
  }).join("\n\n");
2804
2830
  }
@@ -2861,8 +2887,44 @@ function composePrompt(input) {
2861
2887
  return lines.join("\n");
2862
2888
  }
2863
2889
 
2890
+ // src/github/markerReconciliation.ts
2891
+ function isAuthorized(author, p) {
2892
+ const login2 = author.toLowerCase();
2893
+ return login2 !== p.agentUser.toLowerCase() && p.allowedUsers.some((user) => user.toLowerCase() === login2);
2894
+ }
2895
+ function promptWatermark(messageSets) {
2896
+ let watermark = null;
2897
+ for (const messages of messageSets) {
2898
+ for (const message of messages) {
2899
+ if (watermark === null || message.createdAt > watermark) watermark = message.createdAt;
2900
+ }
2901
+ }
2902
+ return watermark;
2903
+ }
2904
+ function messagesBetween(messages, p, watermark, until) {
2905
+ const since = watermark ?? "";
2906
+ return messages.filter(
2907
+ (message) => isAuthorized(message.author, p) && message.createdAt > since && message.createdAt <= until
2908
+ );
2909
+ }
2910
+ function analyseAnswer(messages, p, marker, watermark) {
2911
+ let answeredAt = null;
2912
+ for (const message of messages) {
2913
+ if (message.kind === "issue-body") continue;
2914
+ if (message.author.toLowerCase() !== p.agentUser.toLowerCase()) continue;
2915
+ if (message.createdAt <= marker.createdAt) continue;
2916
+ if (answeredAt === null || message.createdAt > answeredAt) answeredAt = message.createdAt;
2917
+ }
2918
+ const since = watermark ?? marker.createdAt;
2919
+ const toReport = messages.filter(
2920
+ (message) => isAuthorized(message.author, p) && message.createdAt > since
2921
+ );
2922
+ const missed = answeredAt === null ? [] : toReport.filter((message) => message.createdAt <= answeredAt);
2923
+ return { answeredAt, missed, toReport };
2924
+ }
2925
+
2864
2926
  // src/run/runLock.ts
2865
- import { writeFileSync, readFileSync as readFileSync3, unlinkSync, mkdirSync, renameSync, linkSync } from "fs";
2927
+ import { writeFileSync, readFileSync as readFileSync3, unlinkSync, mkdirSync, renameSync, linkSync, statSync } from "fs";
2866
2928
  import { randomUUID } from "crypto";
2867
2929
  import { hostname } from "os";
2868
2930
  import { join as join2 } from "path";
@@ -3022,20 +3084,81 @@ function claimStaleLock(path, token, expected) {
3022
3084
  }
3023
3085
  return true;
3024
3086
  }
3025
- function reclaim(path, command, token, expected) {
3026
- if (!claimStaleLock(path, token, expected)) {
3027
- return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
3028
- }
3029
- const claimed = `${path}.stale.${token}`;
3087
+ var CLAIM_STALE_MS = 6e4;
3088
+ function acquireClaim(path) {
3089
+ const claimPath = `${path}.claim`;
3030
3090
  try {
3031
- write(path, command, token, "wx");
3032
- return { ok: true, handle: makeHandle(path, token) };
3091
+ writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
3092
+ encoding: "utf8",
3093
+ flag: "wx"
3094
+ });
3095
+ return claimPath;
3033
3096
  } catch (err) {
3034
3097
  if (err.code !== "EEXIST") throw err;
3035
- return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
3098
+ }
3099
+ if (!claimIsAbandoned(claimPath)) return null;
3100
+ try {
3101
+ renameSync(claimPath, `${claimPath}.abandoned.${String(process.pid)}`);
3102
+ } catch {
3103
+ return null;
3104
+ }
3105
+ try {
3106
+ unlinkSync(`${claimPath}.abandoned.${String(process.pid)}`);
3107
+ } catch {
3108
+ }
3109
+ try {
3110
+ writeFileSync(claimPath, JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }), {
3111
+ encoding: "utf8",
3112
+ flag: "wx"
3113
+ });
3114
+ return claimPath;
3115
+ } catch {
3116
+ return null;
3117
+ }
3118
+ }
3119
+ function claimIsAbandoned(claimPath) {
3120
+ let at = Number.NaN;
3121
+ try {
3122
+ const raw = JSON.parse(readFileSync3(claimPath, "utf8"));
3123
+ if (raw.at !== void 0) at = Date.parse(raw.at);
3124
+ } catch {
3125
+ }
3126
+ if (Number.isNaN(at)) {
3127
+ try {
3128
+ at = statSync(claimPath).mtimeMs;
3129
+ } catch {
3130
+ return true;
3131
+ }
3132
+ }
3133
+ return Date.now() - at >= CLAIM_STALE_MS;
3134
+ }
3135
+ function reclaim(path, command, token, expected) {
3136
+ const claimPath = acquireClaim(path);
3137
+ if (claimPath === null) {
3138
+ const owner = readOwner(path);
3139
+ const ownerDead = owner !== null && owner.host === hostname() && !isAlive(owner.pid);
3140
+ return { ok: false, heldBy: owner ?? UNKNOWN_OWNER, suspect: ownerDead };
3141
+ }
3142
+ try {
3143
+ if (!claimStaleLock(path, token, expected)) {
3144
+ return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
3145
+ }
3146
+ const claimed = `${path}.stale.${token}`;
3147
+ try {
3148
+ write(path, command, token, "wx");
3149
+ return { ok: true, handle: makeHandle(path, token) };
3150
+ } catch (err) {
3151
+ if (err.code !== "EEXIST") throw err;
3152
+ return { ok: false, heldBy: readOwner(path) ?? UNKNOWN_OWNER, suspect: false };
3153
+ } finally {
3154
+ try {
3155
+ unlinkSync(claimed);
3156
+ } catch {
3157
+ }
3158
+ }
3036
3159
  } finally {
3037
3160
  try {
3038
- unlinkSync(claimed);
3161
+ unlinkSync(claimPath);
3039
3162
  } catch {
3040
3163
  }
3041
3164
  }
@@ -3147,6 +3270,7 @@ function resolveSettings(options) {
3147
3270
  }
3148
3271
  return {
3149
3272
  baseBranch: doWork.baseBranch ?? DEFAULT_DO_WORK.baseBranch,
3273
+ protectedBranches: doWork.protectedBranches ?? DEFAULT_DO_WORK.protectedBranches,
3150
3274
  executor,
3151
3275
  // --model wins; otherwise take the default for the executor in use.
3152
3276
  model: options.model ?? doWork.models?.[executor],
@@ -3254,6 +3378,12 @@ function validateDoWorkConfig(section) {
3254
3378
  validateOptionalString(section, "baseBranch", "doWork.baseBranch");
3255
3379
  validateOptionalInt(section, "maxRunsPerTick", "doWork.maxRunsPerTick", 0, "a non-negative integer (0 = unlimited)");
3256
3380
  validateOptionalInt(section, "lockStaleMinutes", "doWork.lockStaleMinutes", 1, "a positive integer");
3381
+ const protectedBranches = section["protectedBranches"];
3382
+ if (protectedBranches !== void 0 && protectedBranches !== null) {
3383
+ if (!Array.isArray(protectedBranches) || protectedBranches.some((b) => typeof b !== "string" || b.trim().length === 0)) {
3384
+ fail("doWork.protectedBranches must be an array of non-empty strings.");
3385
+ }
3386
+ }
3257
3387
  for (const container of ["models", "prompts"]) {
3258
3388
  const value = section[container];
3259
3389
  if (value === void 0 || value === null) continue;
@@ -3306,7 +3436,7 @@ function issueMatchesFilter(surface, settings) {
3306
3436
  }
3307
3437
  function buildIssueState(issue, linkMap) {
3308
3438
  const issueSurface = getIssueSurface(issue.number);
3309
- const linkedPrs = linkMap.get(issue.number) ?? [];
3439
+ const linkedPrs = linkMap.byIssue.get(issue.number) ?? [];
3310
3440
  const selected = selectLinkedPr(linkedPrs);
3311
3441
  return {
3312
3442
  issueSurface,
@@ -3337,16 +3467,13 @@ function claimIssue(item, settings) {
3337
3467
  }
3338
3468
  }
3339
3469
  function notePickupOnIssue(item, marker) {
3340
- if (item.turn !== "pr-work" || item.pr === null || !item.issueAnalysis.hasNewMessage) return true;
3470
+ if (item.turn !== "pr-work" || item.pr === null || !item.issueAnalysis.hasNewMessage) return null;
3341
3471
  try {
3342
- postMarker(
3472
+ return postMarker(
3343
3473
  "issue",
3344
3474
  item.issue.number,
3345
3475
  `automata do-work: picked this up on pull request #${String(item.pr.number)} \u2014 ${item.pr.url}`
3346
3476
  );
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
3477
  } catch (err) {
3351
3478
  progress(` skipped: could not note the pickup on issue #${String(item.issue.number)} \u2014 ${err.message}
3352
3479
  `);
@@ -3359,20 +3486,37 @@ function notePickupOnIssue(item, marker) {
3359
3486
  return false;
3360
3487
  }
3361
3488
  }
3489
+ function reportIssueMessagesBuriedByNote(item, note, participants) {
3490
+ const watermark = promptWatermark([item.issueAnalysis.messages]);
3491
+ let buried;
3492
+ try {
3493
+ const messages = getIssueSurface(item.issue.number).messages;
3494
+ buried = messagesBetween(messages, participants, watermark, note.createdAt);
3495
+ } catch (err) {
3496
+ progress(` warning: could not re-read issue #${String(item.issue.number)}: ${err.message}
3497
+ `);
3498
+ return 0;
3499
+ }
3500
+ if (buried.length === 0) return 0;
3501
+ const authors = [...new Set(buried.map((message) => message.author))].join(", ");
3502
+ try {
3503
+ postMarker(
3504
+ "issue",
3505
+ item.issue.number,
3506
+ `automata do-work: ${authors} posted here while this issue was being picked up, so ${buried.length === 1 ? "that message was" : "those messages were"} not included in the run. Please post again to have them acted on.`
3507
+ );
3508
+ } catch (err) {
3509
+ progress(` warning: could not report the buried issue message(s): ${err.message}
3510
+ `);
3511
+ }
3512
+ return buried.length;
3513
+ }
3362
3514
  function refreshItem(item, settings) {
3363
3515
  const linkMap = getOpenPrLinkMap();
3364
- return decideWork(
3365
- buildIssueState(item.issue, linkMap),
3366
- settings.participants,
3367
- settings.baseBranch
3368
- );
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;
3516
+ return decideWork(buildIssueState(item.issue, linkMap), settings.participants, {
3517
+ baseBranch: settings.baseBranch,
3518
+ defaultBranch: linkMap.defaultBranch,
3519
+ protectedBranches: settings.protectedBranches
3376
3520
  });
3377
3521
  }
3378
3522
  function readAnsweringSurface(item) {
@@ -3385,62 +3529,76 @@ function readAnsweringSurface(item) {
3385
3529
  function markerSurfaceLabel(item) {
3386
3530
  return item.turn === "pr-work" && item.pr ? `pull request #${String(item.pr.number)}` : `issue #${String(item.issue.number)}`;
3387
3531
  }
3388
- function reconcileMarker(item, marker, participants, runError) {
3389
- const agentUser = participants.agentUser;
3390
- let surfaceMessages = [];
3391
- let answered;
3532
+ function markerSurfaceTarget(item) {
3533
+ return item.turn === "pr-work" && item.pr ? { surface: "pr", number: item.pr.number } : { surface: "issue", number: item.issue.number };
3534
+ }
3535
+ function reportOvertakenMessages(item, analysis) {
3536
+ if (analysis.missed.length === 0) return;
3537
+ const authors = [...new Set(analysis.toReport.map((message) => message.author))].join(", ");
3538
+ const count = analysis.toReport.length;
3539
+ const target = markerSurfaceTarget(item);
3392
3540
  try {
3393
- surfaceMessages = readAnsweringSurface(item);
3394
- answered = agentAnsweredAfter(surfaceMessages, agentUser, marker);
3541
+ postMarker(
3542
+ target.surface,
3543
+ target.number,
3544
+ `automata do-work: ${authors} posted here while this run was already in progress, so ${count === 1 ? "that message was" : "those messages were"} not included in it. Please post again to have them acted on.`
3545
+ );
3546
+ } catch (err) {
3547
+ progress(` warning: could not report the overtaken message(s): ${err.message}
3548
+ `);
3549
+ }
3550
+ }
3551
+ function reconcileMarker(item, marker, participants, watermark, runError) {
3552
+ let analysis;
3553
+ try {
3554
+ analysis = analyseAnswer(readAnsweringSurface(item), participants, marker, watermark);
3395
3555
  } catch (err) {
3396
3556
  progress(
3397
3557
  ` warning: could not re-read issue #${String(item.issue.number)} to check for an answer: ${err.message}
3398
3558
  `
3399
3559
  );
3400
- answered = "unknown";
3401
- }
3402
- if (answered === "unknown") {
3403
- const surface2 = markerSurfaceLabel(item);
3404
- try {
3405
- updateMarker(
3406
- marker,
3407
- `automata do-work: the agent run finished, but automata could not read ${surface2} afterwards to confirm whether an answer was posted. Check this thread and the branch before assuming either. Reply here to have another attempt made.`
3408
- );
3409
- } catch (updateErr) {
3410
- progress(` warning: could not update the marker comment: ${updateErr.message}
3411
- `);
3412
- }
3413
- return { outcome: "answered-no-reply", detail: "could not verify whether an answer was posted" };
3560
+ return reportUnverified(item, marker);
3414
3561
  }
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
- }
3562
+ if (analysis.answeredAt !== null) {
3563
+ reportOvertakenMessages(item, analysis);
3430
3564
  try {
3431
3565
  deleteMarker(marker);
3432
3566
  } catch (err) {
3433
3567
  progress(` warning: could not delete the marker comment: ${err.message}
3434
3568
  `);
3435
3569
  }
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}` };
3570
+ if (analysis.missed.length > 0) {
3571
+ return {
3572
+ outcome: "answered-no-reply",
3573
+ detail: `answered (${String(analysis.toReport.length)} message(s) arrived mid-run and were flagged)`,
3574
+ reason: "flagged"
3575
+ };
3439
3576
  }
3440
- return runError === null ? { outcome: "answered", detail: "answered" } : { outcome: "answered", detail: `answered, but the run reported: ${runError.message}` };
3577
+ return runError === null ? { outcome: "answered", detail: "answered", reason: "answered" } : { outcome: "answered", detail: `answered, but the run reported: ${runError.message}`, reason: "answered" };
3441
3578
  }
3579
+ return reportNoAnswer(item, marker, runError);
3580
+ }
3581
+ function reportUnverified(item, marker) {
3442
3582
  const surface = markerSurfaceLabel(item);
3443
- const explanation = runError === null ? `automata do-work: the agent run finished without posting an answer on ${surface}. It may still have changed the branch \`${item.branch}\` \u2014 check it before assuming otherwise. Reply on ${surface} to have another attempt made.` : `automata do-work: the agent run failed before posting an answer (${runError.message}). It may have left partial changes on the branch \`${item.branch}\`. Reply on ${surface} to have another attempt made.`;
3583
+ try {
3584
+ updateMarker(
3585
+ marker,
3586
+ `automata do-work: the agent run finished, but automata could not read ${surface} afterwards to confirm whether an answer was posted. Check this thread and the branch before assuming either. Reply here to have another attempt made.`
3587
+ );
3588
+ } catch (err) {
3589
+ progress(` warning: could not update the marker comment: ${err.message}
3590
+ `);
3591
+ }
3592
+ return {
3593
+ outcome: "answered-no-reply",
3594
+ detail: "could not verify whether an answer was posted",
3595
+ reason: "unverified"
3596
+ };
3597
+ }
3598
+ function reportNoAnswer(item, marker, runError) {
3599
+ const surface = markerSurfaceLabel(item);
3600
+ const sideEffects = item.turn === "issue-discuss" ? "It may still have created a branch or opened a pull request \u2014 check before assuming otherwise." : `It may still have changed the branch \`${item.branch}\` \u2014 check it before assuming otherwise.`;
3601
+ const explanation = runError === null ? `automata do-work: the agent run finished without posting an answer on ${surface}. ${sideEffects} Reply on ${surface} to have another attempt made.` : `automata do-work: the agent run failed before posting an answer (${runError.message}). ${sideEffects} Reply on ${surface} to have another attempt made.`;
3444
3602
  try {
3445
3603
  updateMarker(marker, explanation);
3446
3604
  } catch (err) {
@@ -3450,7 +3608,7 @@ function reconcileMarker(item, marker, participants, runError) {
3450
3608
  `
3451
3609
  );
3452
3610
  }
3453
- return runError === null ? { outcome: "answered-no-reply", detail: "run finished but posted no answer" } : { outcome: "failed", detail: `run failed: ${runError.message}` };
3611
+ return runError === null ? { outcome: "answered-no-reply", detail: "run finished but posted no answer", reason: "no-answer" } : { outcome: "failed", detail: `run failed: ${runError.message}`, reason: "no-answer" };
3454
3612
  }
3455
3613
  async function invokeExecutor(prompt, settings, silent) {
3456
3614
  if (settings.executor === "codex") {
@@ -3465,26 +3623,28 @@ function repairIssueLink(item, baseBranch) {
3465
3623
  if (branch === baseBranch) {
3466
3624
  progress(` issue #${String(item.issue.number)} is still in discussion (no branch was created).
3467
3625
  `);
3468
- return;
3626
+ return false;
3469
3627
  }
3470
3628
  const pr = getCurrentBranchPr();
3471
3629
  if (!pr) {
3472
3630
  progress(` issue #${String(item.issue.number)} is still in discussion (no pull request).
3473
3631
  `);
3474
- return;
3632
+ return false;
3475
3633
  }
3476
3634
  const closesRef = new RegExp(String.raw`\bcloses\s+#` + String(item.issue.number) + String.raw`\b`, "i");
3477
3635
  if (closesRef.test(pr.body)) {
3478
3636
  progress(` pull request #${String(pr.number)} already closes issue #${String(item.issue.number)}.
3479
3637
  `);
3480
- return;
3638
+ return true;
3481
3639
  }
3482
3640
  addClosesRefToPr(pr.number, item.issue.number);
3483
3641
  progress(` linked pull request #${String(pr.number)} to issue #${String(item.issue.number)}.
3484
3642
  `);
3643
+ return true;
3485
3644
  } catch (err) {
3486
3645
  progress(` warning: could not link a pull request to issue #${String(item.issue.number)}: ${err.message}
3487
3646
  `);
3647
+ return false;
3488
3648
  }
3489
3649
  }
3490
3650
  async function processItem(planned, settings, silent) {
@@ -3530,10 +3690,22 @@ async function processItem(planned, settings, silent) {
3530
3690
  return { ...base, outcome: "skipped", detail: `marker failed: ${err.message}` };
3531
3691
  }
3532
3692
  inFlightMarker = { marker, item };
3533
- if (!notePickupOnIssue(item, marker)) {
3693
+ const note = notePickupOnIssue(item, marker);
3694
+ if (note === false) {
3534
3695
  inFlightMarker = null;
3535
3696
  return { ...base, outcome: "skipped", detail: "issue pickup note failed" };
3536
3697
  }
3698
+ let buriedByNote = 0;
3699
+ if (note !== null) {
3700
+ progress(` noted on issue #${String(item.issue.number)} that the work is on pull request #${String(item.pr?.number ?? 0)}.
3701
+ `);
3702
+ buriedByNote = reportIssueMessagesBuriedByNote(item, note, settings.participants);
3703
+ }
3704
+ const watermark = promptWatermark([
3705
+ item.issueAnalysis.messages,
3706
+ item.prAnalysis?.messages ?? [],
3707
+ item.actionableThreads.flatMap((thread) => thread.comments)
3708
+ ]);
3537
3709
  const prompt = composePrompt({
3538
3710
  item,
3539
3711
  repo: getRepoSlug(),
@@ -3567,13 +3739,29 @@ async function processItem(planned, settings, silent) {
3567
3739
  }
3568
3740
  const ranExecutor = true;
3569
3741
  inFlightMarker = null;
3570
- const reconciled = reconcileMarker(item, marker, settings.participants, runError);
3742
+ const reconciled = reconcileMarker(item, marker, settings.participants, watermark, runError);
3571
3743
  progress(` ${reconciled.detail}
3572
3744
  `);
3745
+ let outcome = reconciled;
3746
+ if (buriedByNote > 0 && outcome.outcome === "answered") {
3747
+ outcome = {
3748
+ outcome: "answered-no-reply",
3749
+ detail: `${outcome.detail} (${String(buriedByNote)} issue message(s) were buried by the pickup note and flagged)`,
3750
+ reason: "flagged"
3751
+ };
3752
+ }
3573
3753
  if (item.turn === "issue-discuss") {
3574
- repairIssueLink(item, settings.baseBranch);
3754
+ const linked = repairIssueLink(item, settings.baseBranch);
3755
+ if (linked && outcome.reason === "no-answer") {
3756
+ outcome = {
3757
+ outcome: "answered",
3758
+ detail: "opened a pull request (no issue comment)",
3759
+ reason: "answered"
3760
+ };
3761
+ progress(" a pull request was opened, so the turn is counted as answered.\n");
3762
+ }
3575
3763
  }
3576
- return { ...base, outcome: reconciled.outcome, detail: reconciled.detail, ranExecutor };
3764
+ return { ...base, outcome: outcome.outcome, detail: outcome.detail, ranExecutor };
3577
3765
  }
3578
3766
  function summarize(reports) {
3579
3767
  out("\nTick summary:\n");
@@ -3670,7 +3858,13 @@ var doWorkCommand = new Command6("do-work").description(
3670
3858
  async function runTick(settings, options) {
3671
3859
  const issues = discoverIssues(settings);
3672
3860
  const linkMap = getOpenPrLinkMap();
3673
- const decisions = issues.map((issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, settings.baseBranch));
3861
+ const decisions = issues.map(
3862
+ (issue) => decideWork(buildIssueState(issue, linkMap), settings.participants, {
3863
+ baseBranch: settings.baseBranch,
3864
+ defaultBranch: linkMap.defaultBranch,
3865
+ protectedBranches: settings.protectedBranches
3866
+ })
3867
+ );
3674
3868
  const items = decisions.flatMap((decision) => decision.kind === "work" ? [decision.item] : []);
3675
3869
  const planText = `Work plan (${String(items.length)} of ${String(issues.length)} issues need an answer):
3676
3870
  ${describePlan(decisions)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "automata-cli",
3
- "version": "0.1.0-feature-030-do-work.212",
3
+ "version": "0.1.0-feature-030-do-work.216",
4
4
  "description": "Automata CLI tool",
5
5
  "type": "module",
6
6
  "bin": {