@tablation/crew 0.82.0 → 0.82.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1365 -1333
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4215,6 +4215,15 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
|
|
|
4215
4215
|
if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
|
|
4216
4216
|
return null;
|
|
4217
4217
|
}
|
|
4218
|
+
var agentLogRefusedKey = (route) => `agent_log_refused:${route}`;
|
|
4219
|
+
function agentLogRefusedItem(route, role, status2, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
4220
|
+
return {
|
|
4221
|
+
kind: "agent_log_refused",
|
|
4222
|
+
key: agentLogRefusedKey(route),
|
|
4223
|
+
since: nowIso,
|
|
4224
|
+
message: `${route}: the tracker refused the ${role} seat's run report (${status2}) \u2014 runs and ticket spend are not being recorded; check the seat key's role`
|
|
4225
|
+
};
|
|
4226
|
+
}
|
|
4218
4227
|
var diskLowKey = (repo) => `disk_low:${repo}`;
|
|
4219
4228
|
var LOW_DISK_BYTES = 2 * 1024 ** 3;
|
|
4220
4229
|
var gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
|
|
@@ -4587,1431 +4596,1441 @@ import { finished } from "stream/promises";
|
|
|
4587
4596
|
import { join as join19, dirname as dirname7, basename as basename7 } from "path";
|
|
4588
4597
|
import { createHash as createHash3 } from "crypto";
|
|
4589
4598
|
|
|
4590
|
-
// src/
|
|
4591
|
-
import {
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
];
|
|
4600
|
-
}
|
|
4601
|
-
var HOOK_PURPOSE = [
|
|
4602
|
-
["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
|
|
4603
|
-
["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
|
|
4604
|
-
["ports", "this worktree's port assignments; prints KEY=value overrides to export"],
|
|
4605
|
-
["handoff", "leave the operator able to open what you built; put its output in your progress comment"],
|
|
4606
|
-
["test", "run the suite"],
|
|
4607
|
-
["build", "build it"],
|
|
4608
|
-
["ci_status", "after you push and before you hand off: exit 0 means CI is green for your branch; on failure fix and push again, and say in your progress comment if it is still pending"]
|
|
4609
|
-
];
|
|
4610
|
-
function hookTable(repo) {
|
|
4611
|
-
const declared = HOOK_PURPOSE.filter(([name]) => repo.hooks[name]);
|
|
4612
|
-
if (declared.length === 0) {
|
|
4613
|
-
return "This repository declares no hooks. Do not guess commands for it \u2014 say so in your progress comment and stop.\n";
|
|
4599
|
+
// src/state.ts
|
|
4600
|
+
import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, existsSync as existsSync11, mkdirSync as mkdirSync6, rmSync as rmSync6, statSync as statSync4, readdirSync as readdirSync4 } from "fs";
|
|
4601
|
+
import { join as join12 } from "path";
|
|
4602
|
+
var EPOCH = "1970-01-01T00:00:00.000Z";
|
|
4603
|
+
var State = class _State {
|
|
4604
|
+
dir;
|
|
4605
|
+
constructor(dir) {
|
|
4606
|
+
this.dir = dir;
|
|
4607
|
+
mkdirSync6(dir, { recursive: true });
|
|
4614
4608
|
}
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
const undeclared = HOOK_PURPOSE.filter(([n]) => !repo.hooks[n]);
|
|
4618
|
-
return [
|
|
4619
|
-
"| hook | what it is for | what it runs |",
|
|
4620
|
-
"| --- | --- | --- |",
|
|
4621
|
-
...declared.map(([name, purpose]) => `| \`${name}\` | ${purpose} | ${inline(repo.hooks[name])} |`),
|
|
4622
|
-
"",
|
|
4623
|
-
"Run these **verbatim**, from the worktree. They come from the repository's own",
|
|
4624
|
-
"configuration, which is the only thing that knows them. A command you invented",
|
|
4625
|
-
"yourself is wrong even when it works, because the next repository will differ.",
|
|
4626
|
-
"",
|
|
4627
|
-
...blocks,
|
|
4628
|
-
...undeclared.length ? [`Not declared here, so there is nothing to run for them: ${undeclared.map(([n]) => `\`${n}\``).join(", ")}.`, ""] : []
|
|
4629
|
-
].join("\n");
|
|
4630
|
-
}
|
|
4631
|
-
function repoPrefix(repo) {
|
|
4632
|
-
return repo.provenance["worktrees.prefix"] !== "default" ? repo.worktrees.prefix.replace(/-$/, "") : void 0;
|
|
4633
|
-
}
|
|
4634
|
-
function branchExample(repo, key) {
|
|
4635
|
-
const prefix = repoPrefix(repo);
|
|
4636
|
-
const template = effectiveBranchTemplate(repo, prefix);
|
|
4637
|
-
return renderBranchName(template, { key, title: "Fix the widget", role: "dev", prefix, lowercase: repo.branch.lowercase });
|
|
4638
|
-
}
|
|
4639
|
-
function repoSection(r, key, heading) {
|
|
4640
|
-
const repo = r.config;
|
|
4641
|
-
const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
|
|
4642
|
-
const branchName = branchExample(repo, key);
|
|
4643
|
-
const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
|
|
4644
|
-
const worktreeName = effectiveWorktreeDirName(
|
|
4645
|
-
repo,
|
|
4646
|
-
r.dir,
|
|
4647
|
-
branchName,
|
|
4648
|
-
key.replace(/^\D+/, ""),
|
|
4649
|
-
// The example ticket has no tag of its own, so `{issue}` is the key.
|
|
4650
|
-
(template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
|
|
4651
|
-
);
|
|
4652
|
-
const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
|
|
4653
|
-
const lines = [
|
|
4654
|
-
"",
|
|
4655
|
-
heading,
|
|
4656
|
-
"",
|
|
4657
|
-
`- Cut it from \`${repo.branch.base}\`, beside the checkout, at`,
|
|
4658
|
-
` \`../${worktreePattern}\` \u2014 for ${key} that is \`../${worktreeName}\`.`,
|
|
4659
|
-
`- Name the branch the way this repository names branches: \`${branchTemplate}\``,
|
|
4660
|
-
` \u2014 for ${key} that is \`${branchName}\`.`,
|
|
4661
|
-
"- Never work in the main checkout. It is the operator's, and the release phase",
|
|
4662
|
-
" uses it.",
|
|
4663
|
-
"",
|
|
4664
|
-
`${key} above is a placeholder, not a real ticket: this section is rendered`,
|
|
4665
|
-
"before any ticket is chosen, so it cannot know a specific ticket's own",
|
|
4666
|
-
"project prefix. Once you have a ticket, its digest row's `branch` and",
|
|
4667
|
-
"`worktree` columns are the real, per-ticket values \u2014 use those verbatim",
|
|
4668
|
-
"over this section's worked example if the two ever disagree."
|
|
4669
|
-
];
|
|
4670
|
-
if (repo.worktrees.copy.length) {
|
|
4671
|
-
lines.push(
|
|
4672
|
-
"",
|
|
4673
|
-
"A worktree is a clean checkout, so it is missing exactly the files git ignores.",
|
|
4674
|
-
"Copy these across from the main checkout before running anything:",
|
|
4675
|
-
"",
|
|
4676
|
-
...repo.worktrees.copy.map((c) => `- \`${c}\``)
|
|
4677
|
-
);
|
|
4609
|
+
path(...parts) {
|
|
4610
|
+
return join12(this.dir, ...parts);
|
|
4678
4611
|
}
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4612
|
+
/**
|
|
4613
|
+
* The timestamp every "new since last poll" check is measured against.
|
|
4614
|
+
*
|
|
4615
|
+
* Read once per cycle and advanced only AFTER every role has been
|
|
4616
|
+
* evaluated: bumping it inside the first role's check would hide that
|
|
4617
|
+
* role's new comments from the second.
|
|
4618
|
+
*/
|
|
4619
|
+
watermark() {
|
|
4620
|
+
try {
|
|
4621
|
+
const v = readFileSync11(this.path(".poll-watermark"), "utf8").trim();
|
|
4622
|
+
return v || EPOCH;
|
|
4623
|
+
} catch {
|
|
4624
|
+
return EPOCH;
|
|
4625
|
+
}
|
|
4688
4626
|
}
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
"## This project's triage policy",
|
|
4693
|
-
"",
|
|
4694
|
-
`\`${repo.docs.triagePolicy}\`, in the repository. Where it and any brief`,
|
|
4695
|
-
"disagree about what a status means or when a ticket may be accepted, the",
|
|
4696
|
-
"policy document wins: it is the project's own decision, and the brief is",
|
|
4697
|
-
"shared with projects that decided differently."
|
|
4698
|
-
);
|
|
4627
|
+
setWatermark(at) {
|
|
4628
|
+
writeFileSync7(this.path(".poll-watermark"), `${at}
|
|
4629
|
+
`);
|
|
4699
4630
|
}
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
"",
|
|
4713
|
-
"Supplied by the crew for THIS run. The brief below is shared by every project",
|
|
4714
|
-
"the crew works on and deliberately names nothing specific; this section is where",
|
|
4715
|
-
"those names come from. Where the two appear to disagree, this section wins.",
|
|
4716
|
-
"",
|
|
4717
|
-
"## The board",
|
|
4718
|
-
"",
|
|
4719
|
-
`- Base URL: \`${route.baseUrl}\``,
|
|
4720
|
-
`- Your key is in the \`${API_KEY_VAR}\` environment variable. Send it as`,
|
|
4721
|
-
` \`Authorization: Bearer $${API_KEY_VAR}\`. **Do not read it out of any file**,`,
|
|
4722
|
-
" and never write it into a comment, a commit or a log line.",
|
|
4723
|
-
`- Send \`User-Agent: ${i.userAgent}\` on every request.`
|
|
4724
|
-
];
|
|
4725
|
-
if (models) {
|
|
4726
|
-
lines.push(
|
|
4727
|
-
"",
|
|
4728
|
-
"Tables, by id \u2014 use these directly rather than listing the workspace to find them:",
|
|
4729
|
-
"",
|
|
4730
|
-
`- Issues: \`${models.issues}\``,
|
|
4731
|
-
`- Comments: \`${models.comments}\``,
|
|
4732
|
-
`- Crew: \`${models.crew}\``,
|
|
4733
|
-
...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : [],
|
|
4734
|
-
...mediaLibraryModelId ? [`- Media Library: \`${mediaLibraryModelId}\` (what a ticket's \`${contract.columns.attachments}\` ids point into)`] : []
|
|
4735
|
-
);
|
|
4631
|
+
/**
|
|
4632
|
+
* Advance to the newest thing this cycle actually saw, never to "now".
|
|
4633
|
+
*
|
|
4634
|
+
* Using the clock would silently skip any comment written between the fetch
|
|
4635
|
+
* and the write — a race that loses work rather than repeating it, which is
|
|
4636
|
+
* the wrong way round. Repeating a cycle is free; missing one is not.
|
|
4637
|
+
*/
|
|
4638
|
+
advanceWatermark(seen) {
|
|
4639
|
+
const stamps = seen.flatMap((s) => [s.created_at, s.updated_at]).filter((v) => typeof v === "string" && v.length > 0);
|
|
4640
|
+
const next = stamps.length ? stamps.reduce((a, b) => a > b ? a : b) : this.watermark();
|
|
4641
|
+
this.setWatermark(next);
|
|
4642
|
+
return next;
|
|
4736
4643
|
}
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
lines.push(
|
|
4741
|
-
"",
|
|
4742
|
-
`- Workspace id: \`${workspaceId}\``,
|
|
4743
|
-
"",
|
|
4744
|
-
"## Attaching a file to a comment",
|
|
4745
|
-
"",
|
|
4746
|
-
"A screenshot or mockup goes on a comment's `attachments` field, which holds",
|
|
4747
|
-
"Media Library record ids, not URLs. Upload the file first, then put the",
|
|
4748
|
-
"returned `id` in the comment's `attachments` array:",
|
|
4749
|
-
"",
|
|
4750
|
-
"```sh",
|
|
4751
|
-
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
4752
|
-
` -X POST "${base}/api/workspaces/${workspaceId}/files" \\`,
|
|
4753
|
-
' -F "file=@shot.png;type=image/png" -F targetMediaLibrary=true',
|
|
4754
|
-
"```",
|
|
4755
|
-
"",
|
|
4756
|
-
"The response is JSON with `id` and `storageKey`. `targetMediaLibrary=true`",
|
|
4757
|
-
"means no field type id has to be looked up. If the upload fails, post the HTTP",
|
|
4758
|
-
"status and response body in your comment \u2014 do not write that uploads are",
|
|
4759
|
-
"impossible."
|
|
4760
|
-
);
|
|
4761
|
-
if (mediaLibraryModelId) {
|
|
4762
|
-
lines.push(
|
|
4763
|
-
"",
|
|
4764
|
-
"## Opening a ticket's attachments",
|
|
4765
|
-
"",
|
|
4766
|
-
`A ticket's or a comment's \`${contract.columns.attachments}\` value is a list of Media Library`,
|
|
4767
|
-
"**record ids** \u2014 not file ids and not URLs. `files/<id>`, `files/<id>/download` and",
|
|
4768
|
-
"`media-library/<id>/...` do not exist for such an id and return 404. For each id,",
|
|
4769
|
-
"read the record, then fetch the file by its storage key:",
|
|
4770
|
-
"",
|
|
4771
|
-
"```sh",
|
|
4772
|
-
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
4773
|
-
` "${base}/api/workspaces/${workspaceId}/data-models/${mediaLibraryModelId}/records/<attachment id>"`,
|
|
4774
|
-
'# \u2192 JSON with `storage_key` ("<uuid>/<filename>") and `mime_type`',
|
|
4775
|
-
`curl -s -o <scratch dir>/<filename> -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
4776
|
-
` "${base}/api/workspaces/${workspaceId}/files/<storage_key>"`,
|
|
4777
|
-
"```",
|
|
4778
|
-
"",
|
|
4779
|
-
"The MCP forms are `records_controller_find_one` (`dataModelId` = the Media Library",
|
|
4780
|
-
"id above) and `files_controller_download` (`uuid` and `filename` = the two halves",
|
|
4781
|
-
"of the storage key). Save the file outside the repository and open it; where it",
|
|
4782
|
-
"disagrees with the description, the picture is the reproduction."
|
|
4783
|
-
);
|
|
4784
|
-
}
|
|
4644
|
+
/** Whole-crew pause, and the per-role sentinels one level down. */
|
|
4645
|
+
isPaused() {
|
|
4646
|
+
return existsSync11(this.path(".crew-paused"));
|
|
4785
4647
|
}
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
"",
|
|
4789
|
-
"## Ships",
|
|
4790
|
-
"",
|
|
4791
|
-
`This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
|
|
4792
|
-
"another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
|
|
4793
|
-
);
|
|
4648
|
+
isRolePaused(role) {
|
|
4649
|
+
return existsSync11(this.path(`.role-paused-${role}`));
|
|
4794
4650
|
}
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
lines.push(
|
|
4798
|
-
"",
|
|
4799
|
-
"## Seats on this ship",
|
|
4800
|
-
"",
|
|
4801
|
-
"The Crew row id of each role's seat on this ship, to write verbatim where a hand-off",
|
|
4802
|
-
"assigns the next lane (a ticket whose digest `mode` is `hybrid`):",
|
|
4803
|
-
"",
|
|
4804
|
-
...seatIds.map(([role, id]) => `- ${role}: \`${id}\``)
|
|
4805
|
-
);
|
|
4651
|
+
pausedRoles(roles) {
|
|
4652
|
+
return new Set(roles.filter((r) => this.isRolePaused(r)));
|
|
4806
4653
|
}
|
|
4807
|
-
|
|
4808
|
-
"",
|
|
4809
|
-
"## What the columns are called here",
|
|
4810
|
-
"",
|
|
4811
|
-
"Every workspace names these itself. Use these names, not the ones you would",
|
|
4812
|
-
"guess, and not the ones another project used.",
|
|
4813
|
-
"",
|
|
4814
|
-
"| meaning | column |",
|
|
4815
|
-
"| --- | --- |",
|
|
4816
|
-
`| ticket key | \`${contract.columns.key}\` |`,
|
|
4817
|
-
`| status | \`${contract.columns.status}\` |`,
|
|
4818
|
-
`| assignee | \`${contract.columns.assignee}\` |`,
|
|
4819
|
-
`| severity | \`${contract.columns.severity}\` |`,
|
|
4820
|
-
`| priority | \`${contract.columns.priority}\` |`,
|
|
4821
|
-
`| blocked by | \`${contract.columns.blockedBy}\` |`,
|
|
4822
|
-
`| needs design | \`${contract.columns.needsDesign}\` |`,
|
|
4823
|
-
`| project | \`${contract.columns.slice}\` |`,
|
|
4824
|
-
`| repo | \`${contract.columns.repo}\` |`,
|
|
4825
|
-
...epicsModelId ? [`| epic | \`${contract.columns.epic}\` |`] : [],
|
|
4826
|
-
"",
|
|
4827
|
-
...epicsModelId ? [
|
|
4828
|
-
"An epic is a body of work a ticket belongs to. Set it when a ticket clearly",
|
|
4829
|
-
"fits an existing epic; proposing a new one is a taxonomy decision \u2014 leave it",
|
|
4830
|
-
"to a person rather than inventing one yourself.",
|
|
4831
|
-
"",
|
|
4832
|
-
"An epic's own status is kept by the crew runner, from its tickets: done once",
|
|
4833
|
-
"every one of them is closed, in progress while any is being worked. Do not",
|
|
4834
|
-
"set it yourself.",
|
|
4835
|
-
""
|
|
4836
|
-
] : [],
|
|
4837
|
-
"Statuses that matter to you:",
|
|
4838
|
-
"",
|
|
4839
|
-
`- Work you may pick up: \`${contract.statuses.approved}\``,
|
|
4840
|
-
`- While you are working it: \`${contract.statuses.building}\``,
|
|
4841
|
-
`- When you hand it on to be checked: \`${contract.statuses.handoff}\``,
|
|
4842
|
-
`- Being checked: \`${contract.statuses.verifying}\``,
|
|
4843
|
-
`- Waiting on a person to answer something: \`${contract.statuses.needsHuman}\``,
|
|
4844
|
-
`- Parked on an unresolved dependency, set by the crew and not by you: \`${contract.statuses.parked}\``,
|
|
4845
|
-
"",
|
|
4846
|
-
`\`${contract.statuses.verified}\` and the closed statuses are **never yours to set**.`
|
|
4847
|
-
);
|
|
4848
|
-
const areaId = route.resolved?.areaId;
|
|
4849
|
-
if (areaId || repos.some((r) => r.id)) {
|
|
4850
|
-
lines.push(
|
|
4851
|
-
"",
|
|
4852
|
-
"## Filing a new ticket",
|
|
4853
|
-
"",
|
|
4854
|
-
"Something broken that is out of scope for the ticket you are working is not",
|
|
4855
|
-
"yours to fix inline \u2014 file it as its own new ticket instead, and keep working",
|
|
4856
|
-
"the one you were on. A ticket filed without its project and repo sits unrouted",
|
|
4857
|
-
"until a person notices and fixes it by hand, so always set:",
|
|
4858
|
-
"",
|
|
4859
|
-
...areaId ? [`- \`${contract.columns.slice}\`: \`${areaId}\` (this route's project, for every ticket you file)`] : [],
|
|
4860
|
-
...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
|
|
4861
|
-
(r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
|
|
4862
|
-
),
|
|
4863
|
-
...authorshipBullets(i.authorship, contract),
|
|
4864
|
-
"",
|
|
4865
|
-
...i.sourceTicket ? [
|
|
4866
|
-
`Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
|
|
4867
|
-
"ticket's description. Only put it in the new ticket's `blockedBy` if your own ticket",
|
|
4868
|
-
"genuinely cannot proceed without the new one being fixed first; merely having noticed it",
|
|
4869
|
-
"nearby is not a dependency.",
|
|
4870
|
-
""
|
|
4871
|
-
] : []
|
|
4872
|
-
);
|
|
4873
|
-
}
|
|
4874
|
-
if (only) {
|
|
4875
|
-
lines.push(...repoSection(only, key, "## Your worktree"));
|
|
4876
|
-
} else {
|
|
4877
|
-
lines.push(
|
|
4878
|
-
"",
|
|
4879
|
-
"## The repositories this board covers",
|
|
4880
|
-
"",
|
|
4881
|
-
"This board spans several repositories, and they do not share a branch name,",
|
|
4882
|
-
"a worktree location or a test command. **The `repo` column of your queue",
|
|
4883
|
-
"says which one your ticket belongs to** \u2014 read the section for THAT one and",
|
|
4884
|
-
"ignore the others. A ticket whose repo is not listed here has no checkout on",
|
|
4885
|
-
"this machine and is not yours to work."
|
|
4886
|
-
);
|
|
4887
|
-
for (const r of repos) {
|
|
4888
|
-
lines.push(...repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
|
|
4889
|
-
lines.push("", `Hooks for ${r.name} \u2014 the only commands you should run here:`, "", hookTable(r.config));
|
|
4890
|
-
}
|
|
4891
|
-
}
|
|
4892
|
-
if (i.repoDir) {
|
|
4893
|
-
lines.push("", `This ticket's work happens in \`${i.repoDir}\`.`);
|
|
4894
|
-
}
|
|
4895
|
-
if (only) lines.push("", "## Hooks \u2014 the only commands you should run", "", hookTable(only.config));
|
|
4896
|
-
return lines.join("\n");
|
|
4897
|
-
}
|
|
4898
|
-
|
|
4899
|
-
// src/contract.ts
|
|
4900
|
-
var DEFAULT_CONTRACT = {
|
|
4901
|
-
columns: {
|
|
4902
|
-
key: "issue_id",
|
|
4903
|
-
tag: "issue_tag",
|
|
4904
|
-
title: "title",
|
|
4905
|
-
status: "status",
|
|
4906
|
-
assignee: "assignee_id",
|
|
4907
|
-
priority: "priority",
|
|
4908
|
-
severity: "severity",
|
|
4909
|
-
slice: "project_id",
|
|
4910
|
-
repo: "repo_id",
|
|
4911
|
-
parent: "parent_id",
|
|
4912
|
-
blockedBy: "blocked_by",
|
|
4913
|
-
updatedAt: "updated_at",
|
|
4914
|
-
needsDesign: "needs_design",
|
|
4915
|
-
attachments: "attachments",
|
|
4916
|
-
workMode: "work_mode",
|
|
4917
|
-
reportType: "report_type",
|
|
4918
|
-
epic: "epic_id",
|
|
4919
|
-
author: "filed_by_id",
|
|
4920
|
-
ship: "ship_id",
|
|
4921
|
-
heldBy: "held_by_ship_id",
|
|
4922
|
-
resolutionNote: "resolution_note",
|
|
4923
|
-
verifiedSha: "verified_sha",
|
|
4924
|
-
prRef: "pr_ref",
|
|
4925
|
-
externalId: "external_id",
|
|
4926
|
-
position: "position",
|
|
4927
|
-
agentRuns: "agent_runs",
|
|
4928
|
-
tokensIn: "tokens_in",
|
|
4929
|
-
tokensOut: "tokens_out",
|
|
4930
|
-
cacheReadTokens: "cache_read_tokens",
|
|
4931
|
-
cacheWriteTokens: "cache_write_tokens",
|
|
4932
|
-
costUsd: "cost_usd"
|
|
4933
|
-
},
|
|
4934
|
-
comments: {
|
|
4935
|
-
parent: "ticket_id",
|
|
4936
|
-
body: "body",
|
|
4937
|
-
author: "team_member_id",
|
|
4938
|
-
createdAt: "created_at",
|
|
4939
|
-
ship: "ship_id",
|
|
4940
|
-
publicReply: "is_public_reply",
|
|
4941
|
-
authorName: "author_name"
|
|
4942
|
-
},
|
|
4943
|
-
statuses: {
|
|
4944
|
-
open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
|
|
4945
|
-
resolved: [
|
|
4946
|
-
"verified",
|
|
4947
|
-
"closed_deployed",
|
|
4948
|
-
"closed_wont_fix",
|
|
4949
|
-
"closed_duplicate",
|
|
4950
|
-
"closed_completed",
|
|
4951
|
-
"closed_cant_reproduce"
|
|
4952
|
-
],
|
|
4953
|
-
approved: "accepted",
|
|
4954
|
-
parked: "blocked",
|
|
4955
|
-
building: "in_progress",
|
|
4956
|
-
handoff: "fixed",
|
|
4957
|
-
verifying: "qa",
|
|
4958
|
-
verified: "verified",
|
|
4959
|
-
deployed: "closed_deployed",
|
|
4960
|
-
needsHuman: "needs_info",
|
|
4961
|
-
reviewing: null,
|
|
4962
|
-
wontFix: "closed_wont_fix",
|
|
4963
|
-
draft: "draft"
|
|
4964
|
-
},
|
|
4965
|
-
epics: {
|
|
4966
|
-
statusColumn: "status",
|
|
4967
|
-
keyColumn: "epic_id",
|
|
4968
|
-
statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
|
|
4969
|
-
},
|
|
4970
|
-
priorityOrder: ["p0", "p1", "p2", "p3"],
|
|
4971
|
-
severityOrder: ["s1", "s2", "s3", "s4"],
|
|
4972
|
-
unknownPriorityRank: 2,
|
|
4973
|
-
coordinatingValue: "coordinating",
|
|
4974
|
-
manualWorkMode: "manual",
|
|
4975
|
-
hybridWorkMode: "hybrid",
|
|
4976
|
-
repoExemptReportTypes: ["coordinating", "question", "investigation"]
|
|
4977
|
-
};
|
|
4978
|
-
function closedStatuses(c) {
|
|
4979
|
-
return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
|
|
4980
|
-
}
|
|
4981
|
-
function resolveContract(override) {
|
|
4982
|
-
if (!override) return DEFAULT_CONTRACT;
|
|
4983
|
-
return {
|
|
4984
|
-
columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
|
|
4985
|
-
comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
|
|
4986
|
-
statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
|
|
4987
|
-
epics: {
|
|
4988
|
-
...DEFAULT_CONTRACT.epics,
|
|
4989
|
-
...override.epics,
|
|
4990
|
-
statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
|
|
4991
|
-
},
|
|
4992
|
-
priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
|
|
4993
|
-
severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
|
|
4994
|
-
unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
|
|
4995
|
-
coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
|
|
4996
|
-
manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
|
|
4997
|
-
hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
|
|
4998
|
-
// A workspace overriding `coordinatingValue` alone (renaming the rollup
|
|
4999
|
-
// marker) still gets that renamed value exempted here by default,
|
|
5000
|
-
// without also having to repeat it — only an explicit
|
|
5001
|
-
// `repoExemptReportTypes` overrides this derived default.
|
|
5002
|
-
repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
|
|
5003
|
-
};
|
|
5004
|
-
}
|
|
5005
|
-
function validateContract(c) {
|
|
5006
|
-
const problems = [];
|
|
5007
|
-
const open = new Set(c.statuses.open);
|
|
5008
|
-
const resolved = new Set(c.statuses.resolved);
|
|
5009
|
-
for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
|
|
5010
|
-
const v = c.statuses[role];
|
|
5011
|
-
if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
|
|
4654
|
+
pause(role) {
|
|
4655
|
+
writeFileSync7(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
|
|
5012
4656
|
}
|
|
5013
|
-
|
|
5014
|
-
|
|
4657
|
+
resume(role) {
|
|
4658
|
+
rmSync6(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
|
|
5015
4659
|
}
|
|
5016
|
-
|
|
5017
|
-
|
|
4660
|
+
/**
|
|
4661
|
+
* The release phase (merge, deploy, release) has its own sentinel, one
|
|
4662
|
+
* level below the whole-crew pause: `crew pause release` stops shipping
|
|
4663
|
+
* while agents keep working.
|
|
4664
|
+
*/
|
|
4665
|
+
isReleasePaused() {
|
|
4666
|
+
return existsSync11(this.path(".release-paused"));
|
|
5018
4667
|
}
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
);
|
|
4668
|
+
setReleasePaused(paused) {
|
|
4669
|
+
if (paused) writeFileSync7(this.path(".release-paused"), "");
|
|
4670
|
+
else rmSync6(this.path(".release-paused"), { force: true });
|
|
5023
4671
|
}
|
|
5024
|
-
|
|
5025
|
-
|
|
4672
|
+
/**
|
|
4673
|
+
* Why a release cycle must not run right now, or null when it may. Every
|
|
4674
|
+
* release entry point checks this first — the release timer, `crew release`,
|
|
4675
|
+
* `crew merge`, `crew deploy` and the inline release a `run` cycle used to
|
|
4676
|
+
* do — so a paused ship cannot merge, deploy, or file release alerts.
|
|
4677
|
+
* A release already in flight when the pause lands finishes; only new
|
|
4678
|
+
* cycles are refused.
|
|
4679
|
+
*/
|
|
4680
|
+
releaseSkipReason() {
|
|
4681
|
+
if (this.isPaused()) return "the crew is paused";
|
|
4682
|
+
if (this.isReleasePaused()) return "releases are paused";
|
|
4683
|
+
return null;
|
|
5026
4684
|
}
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
4685
|
+
/**
|
|
4686
|
+
* Take an exclusive lock, or report who holds it.
|
|
4687
|
+
*
|
|
4688
|
+
* A timer fires every couple of minutes and an agent run takes minutes, so
|
|
4689
|
+
* overlapping fires are certain rather than hypothetical — and two agents
|
|
4690
|
+
* on one checkout would fight over the same worktree and the same tickets.
|
|
4691
|
+
*
|
|
4692
|
+
* A lock naming a DEAD pid is stale and taken over: a run killed mid-cycle
|
|
4693
|
+
* (a reboot, a Ctrl-C) must not wedge the crew until someone notices.
|
|
4694
|
+
*/
|
|
4695
|
+
/**
|
|
4696
|
+
* Lock names become filenames, so anything that could be read as a path has
|
|
4697
|
+
* to go. A scope like `route/repo` otherwise produced
|
|
4698
|
+
* `.release-route/repo.lock` — a write into a directory that does not exist.
|
|
4699
|
+
*/
|
|
4700
|
+
static safe(name) {
|
|
4701
|
+
return name.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
5031
4702
|
}
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
if (c.statuses.parked === c.statuses.approved) {
|
|
5036
|
-
problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
|
|
5037
|
-
}
|
|
5038
|
-
const epicValues = Object.values(c.epics.statuses);
|
|
5039
|
-
if (new Set(epicValues).size !== epicValues.length) {
|
|
5040
|
-
problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
|
|
5041
|
-
}
|
|
5042
|
-
if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
|
|
5043
|
-
if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
|
|
5044
|
-
for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
|
|
5045
|
-
if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
|
|
5046
|
-
}
|
|
5047
|
-
return problems;
|
|
5048
|
-
}
|
|
5049
|
-
var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
|
|
5050
|
-
|
|
5051
|
-
// src/agent-log.ts
|
|
5052
|
-
import { existsSync as existsSync11, mkdirSync as mkdirSync6, readdirSync as readdirSync4, readFileSync as readFileSync11, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "fs";
|
|
5053
|
-
import { join as join12 } from "path";
|
|
5054
|
-
|
|
5055
|
-
// src/pending-backoff.ts
|
|
5056
|
-
var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
|
|
5057
|
-
|
|
5058
|
-
// src/agent-log.ts
|
|
5059
|
-
var AgentLogUnreachableError = class extends Error {
|
|
5060
|
-
};
|
|
5061
|
-
var AgentLogHttpError = class extends Error {
|
|
5062
|
-
status;
|
|
5063
|
-
constructor(message, status2) {
|
|
5064
|
-
super(message);
|
|
5065
|
-
this.status = status2;
|
|
5066
|
-
}
|
|
5067
|
-
};
|
|
5068
|
-
async function request(target, method, path, body) {
|
|
5069
|
-
const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
|
|
5070
|
-
const res = await fetch(url, {
|
|
5071
|
-
method,
|
|
5072
|
-
headers: {
|
|
5073
|
-
Authorization: `Bearer ${target.apiKey}`,
|
|
5074
|
-
"User-Agent": target.userAgent,
|
|
5075
|
-
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
5076
|
-
},
|
|
5077
|
-
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
5078
|
-
});
|
|
5079
|
-
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
5080
|
-
return await res.json();
|
|
5081
|
-
}
|
|
5082
|
-
var issueColumns = /* @__PURE__ */ new Map();
|
|
5083
|
-
function columnsOf(target, modelId) {
|
|
5084
|
-
const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
|
|
5085
|
-
let p = issueColumns.get(key);
|
|
5086
|
-
if (!p) {
|
|
5087
|
-
p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
|
|
5088
|
-
p.catch(() => issueColumns.delete(key));
|
|
5089
|
-
issueColumns.set(key, p);
|
|
5090
|
-
}
|
|
5091
|
-
return p;
|
|
5092
|
-
}
|
|
5093
|
-
async function findTicketRecord(target, t, ref) {
|
|
5094
|
-
const filters = {
|
|
5095
|
-
combinator: "OR",
|
|
5096
|
-
conditions: [
|
|
5097
|
-
{ columnName: t.keyColumn, operator: "EQ", value: ref },
|
|
5098
|
-
{ columnName: t.tagColumn, operator: "EQ", value: ref }
|
|
5099
|
-
]
|
|
5100
|
-
};
|
|
5101
|
-
const rows = await request(
|
|
5102
|
-
target,
|
|
5103
|
-
"GET",
|
|
5104
|
-
`/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
|
|
5105
|
-
);
|
|
5106
|
-
return rows.length === 1 ? rows[0].id : void 0;
|
|
5107
|
-
}
|
|
5108
|
-
function counterIncrements(run2, cols, have) {
|
|
5109
|
-
const figures = [
|
|
5110
|
-
[cols.agentRuns, 1],
|
|
5111
|
-
[cols.tokensIn, run2.tokensIn],
|
|
5112
|
-
[cols.tokensOut, run2.tokensOut],
|
|
5113
|
-
[cols.cacheReadTokens, run2.cacheReadTokens],
|
|
5114
|
-
[cols.cacheWriteTokens, run2.cacheWriteTokens],
|
|
5115
|
-
[cols.costUsd, run2.costUsd]
|
|
5116
|
-
];
|
|
5117
|
-
const out = {};
|
|
5118
|
-
for (const [column, value2] of figures) {
|
|
5119
|
-
if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
|
|
5120
|
-
}
|
|
5121
|
-
return out;
|
|
5122
|
-
}
|
|
5123
|
-
async function post(target, path, body) {
|
|
5124
|
-
const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
|
|
5125
|
-
const res = await fetch(url, {
|
|
5126
|
-
method: "POST",
|
|
5127
|
-
headers: {
|
|
5128
|
-
Authorization: `Bearer ${target.apiKey}`,
|
|
5129
|
-
// Cloudflare 403s default agents on this host — same header connect.ts sends.
|
|
5130
|
-
"User-Agent": target.userAgent,
|
|
5131
|
-
"Content-Type": "application/json"
|
|
5132
|
-
},
|
|
5133
|
-
body: JSON.stringify(body)
|
|
5134
|
-
});
|
|
5135
|
-
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
5136
|
-
return await res.json();
|
|
5137
|
-
}
|
|
5138
|
-
async function reportAgentRun(target, run2) {
|
|
5139
|
-
let ticketRecordId;
|
|
5140
|
-
if (target.tickets && run2.ticketReference) {
|
|
4703
|
+
acquire(rawName) {
|
|
4704
|
+
const name = _State.safe(rawName);
|
|
4705
|
+
const file = this.path(`.${name}.lock`);
|
|
5141
4706
|
try {
|
|
5142
|
-
|
|
4707
|
+
const existing = Number.parseInt(readFileSync11(file, "utf8").trim(), 10);
|
|
4708
|
+
if (Number.isFinite(existing) && existing > 0) {
|
|
4709
|
+
try {
|
|
4710
|
+
process.kill(existing, 0);
|
|
4711
|
+
return { ok: false, heldBy: existing };
|
|
4712
|
+
} catch {
|
|
4713
|
+
}
|
|
4714
|
+
}
|
|
5143
4715
|
} catch {
|
|
5144
4716
|
}
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
agentId: target.agentId,
|
|
5148
|
-
ticketReference: run2.ticketReference,
|
|
5149
|
-
outcome: run2.outcome,
|
|
5150
|
-
startedAt: run2.startedAt,
|
|
5151
|
-
finishedAt: run2.finishedAt,
|
|
5152
|
-
source: "client",
|
|
5153
|
-
client: "crew",
|
|
5154
|
-
model: run2.model,
|
|
5155
|
-
tokensIn: run2.tokensIn,
|
|
5156
|
-
tokensOut: run2.tokensOut,
|
|
5157
|
-
cacheReadTokens: run2.cacheReadTokens,
|
|
5158
|
-
cacheWriteTokens: run2.cacheWriteTokens,
|
|
5159
|
-
costUsd: run2.costUsd,
|
|
5160
|
-
numTurns: run2.numTurns,
|
|
5161
|
-
prefixTokens: run2.prefixTokens,
|
|
5162
|
-
promptVersion: run2.promptVersion,
|
|
5163
|
-
promptSha: run2.promptSha
|
|
5164
|
-
};
|
|
5165
|
-
const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
|
|
5166
|
-
let entry;
|
|
5167
|
-
try {
|
|
5168
|
-
entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run2.harness, provider: run2.provider });
|
|
5169
|
-
} catch (e) {
|
|
5170
|
-
if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
|
|
5171
|
-
if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
|
|
5172
|
-
if (e.status !== 400 || !run2.harness && !run2.provider && !ticketRecordId) throw e;
|
|
5173
|
-
entry = await post(target, "/agents/log", body);
|
|
5174
|
-
}
|
|
5175
|
-
for (const cycle of run2.cycles) {
|
|
5176
|
-
await post(target, `/agents/log/${entry.id}/cycles`, {
|
|
5177
|
-
cycleIndex: cycle.cycleIndex,
|
|
5178
|
-
occurredAt: cycle.occurredAt,
|
|
5179
|
-
thinking: cycle.thinking,
|
|
5180
|
-
...cycle.role ? { role: cycle.role } : {}
|
|
5181
|
-
});
|
|
5182
|
-
}
|
|
5183
|
-
const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run2) : void 0;
|
|
5184
|
-
return {
|
|
5185
|
-
agentLogId: entry.id,
|
|
5186
|
-
...counters?.warning ? { counterWarning: counters.warning } : {},
|
|
5187
|
-
...counters?.retry ? { counterRetry: counters.retry } : {}
|
|
5188
|
-
};
|
|
5189
|
-
}
|
|
5190
|
-
var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
|
|
5191
|
-
async function writeCounters(target, w) {
|
|
5192
|
-
const have = await columnsOf(target, w.issuesModelId);
|
|
5193
|
-
const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
|
|
5194
|
-
if (Object.keys(body).length === 0) return;
|
|
5195
|
-
await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
|
|
5196
|
-
}
|
|
5197
|
-
async function addRunToTicket(target, t, recordId, run2) {
|
|
5198
|
-
const write = {
|
|
5199
|
-
issuesModelId: t.issuesModelId,
|
|
5200
|
-
recordId,
|
|
5201
|
-
ticketReference: run2.ticketReference,
|
|
5202
|
-
counters: t.counters,
|
|
5203
|
-
body: counterIncrements(run2, t.counters, new Set(Object.values(t.counters)))
|
|
5204
|
-
};
|
|
5205
|
-
try {
|
|
5206
|
-
await writeCounters(target, write);
|
|
5207
|
-
return {};
|
|
5208
|
-
} catch (e) {
|
|
4717
|
+
writeFileSync7(file, `${process.pid}
|
|
4718
|
+
`);
|
|
5209
4719
|
return {
|
|
5210
|
-
|
|
5211
|
-
|
|
4720
|
+
ok: true,
|
|
4721
|
+
release: () => {
|
|
4722
|
+
try {
|
|
4723
|
+
rmSync6(file, { force: true });
|
|
4724
|
+
} catch {
|
|
4725
|
+
}
|
|
4726
|
+
}
|
|
5212
4727
|
};
|
|
5213
4728
|
}
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
const dir = queueDir(stateDir);
|
|
5222
|
-
if (!existsSync11(dir)) return 0;
|
|
5223
|
-
let sent = 0;
|
|
5224
|
-
for (const f of readdirSync4(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
5225
|
-
const path = join12(dir, f);
|
|
5226
|
-
let q;
|
|
4729
|
+
/**
|
|
4730
|
+
* Whether a release is running right now: any `.release-<scope>.lock` in the
|
|
4731
|
+
* state directory naming a live process (the locks `releasePhase` takes per
|
|
4732
|
+
* scope). A lock naming a dead pid is ignored, as `acquire` ignores it.
|
|
4733
|
+
*/
|
|
4734
|
+
releaseInProgress() {
|
|
4735
|
+
let names;
|
|
5227
4736
|
try {
|
|
5228
|
-
|
|
4737
|
+
names = readdirSync4(this.dir);
|
|
5229
4738
|
} catch {
|
|
5230
|
-
|
|
4739
|
+
return false;
|
|
5231
4740
|
}
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
|
|
5239
|
-
version: 1,
|
|
5240
|
-
route: q.route,
|
|
5241
|
-
role: q.role,
|
|
5242
|
-
target: q.target,
|
|
5243
|
-
write: sentRun.counterRetry,
|
|
5244
|
-
attempts: 0,
|
|
5245
|
-
nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5246
|
-
});
|
|
5247
|
-
rmSync6(path, { force: true });
|
|
5248
|
-
sent += 1;
|
|
5249
|
-
log.emit(`sent a queued Agent Log report (${f})`);
|
|
5250
|
-
} catch (e) {
|
|
5251
|
-
if (!(e instanceof AgentLogUnreachableError)) {
|
|
5252
|
-
rmSync6(path, { force: true });
|
|
5253
|
-
log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
|
|
4741
|
+
for (const n of names) {
|
|
4742
|
+
if (!/^\.release-.+\.lock$/.test(n)) continue;
|
|
4743
|
+
let pid = 0;
|
|
4744
|
+
try {
|
|
4745
|
+
pid = Number.parseInt(readFileSync11(this.path(n), "utf8").trim(), 10);
|
|
4746
|
+
} catch {
|
|
5254
4747
|
continue;
|
|
5255
4748
|
}
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
}));
|
|
5263
|
-
if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
|
|
4749
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
4750
|
+
try {
|
|
4751
|
+
process.kill(pid, 0);
|
|
4752
|
+
return true;
|
|
4753
|
+
} catch {
|
|
4754
|
+
}
|
|
5264
4755
|
}
|
|
5265
|
-
|
|
5266
|
-
return sent;
|
|
5267
|
-
}
|
|
5268
|
-
var countersDir = (stateDir) => join12(stateDir, "pending-ticket-counters");
|
|
5269
|
-
function queueTicketCounters(stateDir, name, q) {
|
|
5270
|
-
mkdirSync6(countersDir(stateDir), { recursive: true });
|
|
5271
|
-
writeFileSync7(join12(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
|
|
5272
|
-
}
|
|
5273
|
-
async function replayQueuedTicketCounters(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
5274
|
-
const dir = countersDir(stateDir);
|
|
5275
|
-
if (!existsSync11(dir)) return 0;
|
|
5276
|
-
let sent = 0;
|
|
5277
|
-
for (const f of readdirSync4(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
5278
|
-
const path = join12(dir, f);
|
|
5279
|
-
let q;
|
|
5280
|
-
try {
|
|
5281
|
-
q = JSON.parse(readFileSync11(path, "utf8"));
|
|
5282
|
-
} catch {
|
|
5283
|
-
continue;
|
|
5284
|
-
}
|
|
5285
|
-
if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
|
|
5286
|
-
try {
|
|
5287
|
-
const apiKey = await keyFor(q.role);
|
|
5288
|
-
if (!apiKey) continue;
|
|
5289
|
-
await writeCounters({ ...q.target, apiKey }, q.write);
|
|
5290
|
-
rmSync6(path, { force: true });
|
|
5291
|
-
sent += 1;
|
|
5292
|
-
log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
|
|
5293
|
-
} catch (e) {
|
|
5294
|
-
if (!isRetryable(e)) {
|
|
5295
|
-
rmSync6(path, { force: true });
|
|
5296
|
-
log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
|
|
5297
|
-
continue;
|
|
5298
|
-
}
|
|
5299
|
-
const attempts = q.attempts + 1;
|
|
5300
|
-
writeFileSync7(path, JSON.stringify({
|
|
5301
|
-
...q,
|
|
5302
|
-
attempts,
|
|
5303
|
-
warned: true,
|
|
5304
|
-
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
5305
|
-
}));
|
|
5306
|
-
if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
|
|
5307
|
-
}
|
|
5308
|
-
}
|
|
5309
|
-
return sent;
|
|
5310
|
-
}
|
|
5311
|
-
|
|
5312
|
-
// src/ticket-attribution.ts
|
|
5313
|
-
var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
|
|
5314
|
-
function ticketMentions(text) {
|
|
5315
|
-
return [...text.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
|
|
5316
|
-
}
|
|
5317
|
-
var TicketAttributionTracker = class {
|
|
5318
|
-
#tokensByTicket = /* @__PURE__ */ new Map();
|
|
5319
|
-
#turns = 0;
|
|
5320
|
-
#current;
|
|
5321
|
-
/** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
|
|
5322
|
-
#keyByRecord = /* @__PURE__ */ new Map();
|
|
5323
|
-
#lastBy = {};
|
|
5324
|
-
/**
|
|
5325
|
-
* The ticket key most recently named in the transcript, or undefined before any.
|
|
5326
|
-
* Only the FIRST key in a turn counts: a closing summary names its own
|
|
5327
|
-
* ticket first and then lists others ("Other tickets", "Blocked by", "See
|
|
5328
|
-
* also"), and those trailing keys are not what the run worked (CREW-2598).
|
|
5329
|
-
*/
|
|
5330
|
-
get current() {
|
|
5331
|
-
return this.#current;
|
|
5332
|
-
}
|
|
5333
|
-
/** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
|
|
5334
|
-
note(text) {
|
|
5335
|
-
const mentions = ticketMentions(text);
|
|
5336
|
-
if (mentions.length > 0) this.#current = mentions[0];
|
|
5337
|
-
}
|
|
5338
|
-
/** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
|
|
5339
|
-
add(text, tokens) {
|
|
5340
|
-
const mentions = ticketMentions(text);
|
|
5341
|
-
if (mentions.length > 0) this.#current = mentions[0];
|
|
5342
|
-
if (this.#current === void 0 || tokens <= 0) return;
|
|
5343
|
-
this.#turns++;
|
|
5344
|
-
this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
|
|
4756
|
+
return false;
|
|
5345
4757
|
}
|
|
5346
4758
|
/**
|
|
5347
|
-
*
|
|
5348
|
-
*
|
|
5349
|
-
*
|
|
5350
|
-
*
|
|
5351
|
-
* seen before is ignored: its uuid cannot be resolved to a key.
|
|
4759
|
+
* Like `acquire`, but for a resource with `max` interchangeable slots
|
|
4760
|
+
* rather than one exclusive holder — this machine's overall concurrency
|
|
4761
|
+
* capacity (ISSUE-381), which nothing needs to name a particular slot to
|
|
4762
|
+
* use. Takes whichever numbered slot is free first.
|
|
5352
4763
|
*/
|
|
5353
|
-
|
|
5354
|
-
const
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
4764
|
+
acquireN(rawPrefix, max) {
|
|
4765
|
+
const heldBy = [];
|
|
4766
|
+
for (let i = 1; i <= max; i++) {
|
|
4767
|
+
const got = this.acquire(`${rawPrefix}-${i}`);
|
|
4768
|
+
if (got.ok) return got;
|
|
4769
|
+
heldBy.push(got.heldBy);
|
|
4770
|
+
}
|
|
4771
|
+
return { ok: false, heldBy };
|
|
5361
4772
|
}
|
|
5362
4773
|
/**
|
|
5363
|
-
*
|
|
5364
|
-
*
|
|
5365
|
-
*
|
|
5366
|
-
*
|
|
5367
|
-
*
|
|
5368
|
-
*
|
|
5369
|
-
*
|
|
5370
|
-
*
|
|
5371
|
-
*
|
|
5372
|
-
*
|
|
5373
|
-
*
|
|
4774
|
+
* The run lock (ISSUE-381, CREW-2821): role-capped AND capacity-limited,
|
|
4775
|
+
* checked together.
|
|
4776
|
+
*
|
|
4777
|
+
* Two sessions of one role must never take the same ticket. Before
|
|
4778
|
+
* CREW-2821 that was guaranteed by an exclusive per-role lock; now the
|
|
4779
|
+
* guarantee is the per-ticket lock (`acquireTicket`), so the role lock is a
|
|
4780
|
+
* counted one: `roleMax` interchangeable slots, default 1. Slot 1 keeps the
|
|
4781
|
+
* historical name `crew-role-<role>` (crew-macos reads it for the live pid),
|
|
4782
|
+
* further slots are `crew-role-<role>-<n>`. `max` is this machine's separate
|
|
4783
|
+
* limit on how many runs of ANY role may overlap; a role that clears its own
|
|
4784
|
+
* lock but finds every slot taken must give its role lock back, or a
|
|
4785
|
+
* locked-but-not-running role would wedge itself out for the rest of the
|
|
4786
|
+
* machine's uptime.
|
|
5374
4787
|
*/
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
4788
|
+
acquireRun(role, max, roleMax = 1) {
|
|
4789
|
+
let roleLock;
|
|
4790
|
+
const roleHolders = [];
|
|
4791
|
+
for (let i = 1; i <= roleMax; i++) {
|
|
4792
|
+
const got = this.acquire(i === 1 ? `crew-role-${role}` : `crew-role-${role}-${i}`);
|
|
4793
|
+
if (got.ok) {
|
|
4794
|
+
roleLock = got;
|
|
4795
|
+
break;
|
|
4796
|
+
}
|
|
4797
|
+
roleHolders.push(got.heldBy);
|
|
5379
4798
|
}
|
|
5380
|
-
if (
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
let total = 0;
|
|
5386
|
-
for (const n of this.#tokensByTicket.values()) total += n;
|
|
5387
|
-
const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
|
|
5388
|
-
return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
|
|
4799
|
+
if (!roleLock) {
|
|
4800
|
+
return {
|
|
4801
|
+
ok: false,
|
|
4802
|
+
reason: roleMax === 1 ? `${role} is already running (pid ${roleHolders[0]})` : `${role} is already running ${roleMax} agents (its roleConcurrency cap; pid${roleHolders.length > 1 ? "s" : ""} ${[...new Set(roleHolders)].join(", ")})`
|
|
4803
|
+
};
|
|
5389
4804
|
}
|
|
5390
|
-
|
|
4805
|
+
const slot = this.acquireN("crew-slot", max);
|
|
4806
|
+
if (!slot.ok) {
|
|
4807
|
+
roleLock.release();
|
|
4808
|
+
return {
|
|
4809
|
+
ok: false,
|
|
4810
|
+
reason: `at capacity \u2014 ${max} agent(s) already running (pid${slot.heldBy.length > 1 ? "s" : ""} ${slot.heldBy.join(", ")})`
|
|
4811
|
+
};
|
|
4812
|
+
}
|
|
4813
|
+
return { ok: true, release: () => {
|
|
4814
|
+
slot.release();
|
|
4815
|
+
roleLock.release();
|
|
4816
|
+
} };
|
|
5391
4817
|
}
|
|
5392
4818
|
/**
|
|
5393
|
-
* The ticket
|
|
5394
|
-
*
|
|
5395
|
-
*
|
|
5396
|
-
*
|
|
4819
|
+
* The per-ticket run lock (CREW-2821): held for the life of a run that works
|
|
4820
|
+
* the ticket (`crew-ticket-<record id>`), so a second run of the same role
|
|
4821
|
+
* on this ship walks past it instead of being handed the first run's
|
|
4822
|
+
* ticket. Same stale-pid rule as every lock here.
|
|
5397
4823
|
*/
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
let bestTokens = -1;
|
|
5401
|
-
for (const [ticket, tokens] of this.#tokensByTicket) {
|
|
5402
|
-
if (tokens > bestTokens) {
|
|
5403
|
-
best = ticket;
|
|
5404
|
-
bestTokens = tokens;
|
|
5405
|
-
}
|
|
5406
|
-
}
|
|
5407
|
-
return best;
|
|
5408
|
-
}
|
|
5409
|
-
};
|
|
5410
|
-
function isClaimWrite(tool, input) {
|
|
5411
|
-
if (!tool || !/records_controller_update$/.test(tool)) return false;
|
|
5412
|
-
const body = input?.body;
|
|
5413
|
-
return typeof body === "object" && body !== null && body.status === "in_progress";
|
|
5414
|
-
}
|
|
5415
|
-
var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
|
|
5416
|
-
function classifyWrite(tool, input) {
|
|
5417
|
-
if (!tool) return void 0;
|
|
5418
|
-
const body = input?.body;
|
|
5419
|
-
if (typeof body !== "object" || body === null) return void 0;
|
|
5420
|
-
const b = body;
|
|
5421
|
-
if (/records_controller_update$/.test(tool)) {
|
|
5422
|
-
const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
|
|
5423
|
-
if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
|
|
5424
|
-
if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
|
|
4824
|
+
acquireTicket(recordId) {
|
|
4825
|
+
return this.acquire(`crew-ticket-${recordId}`);
|
|
5425
4826
|
}
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
}
|
|
5429
|
-
|
|
5430
|
-
// src/handoff-transcript.ts
|
|
5431
|
-
var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
|
|
5432
|
-
function resultText(content) {
|
|
5433
|
-
if (typeof content === "string") return content;
|
|
5434
|
-
if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
|
|
5435
|
-
return "";
|
|
5436
|
-
}
|
|
5437
|
-
function isTransportFailure(isError, text) {
|
|
5438
|
-
if (!isError) {
|
|
4827
|
+
/** Whether a live run on this ship holds `recordId`'s ticket lock. */
|
|
4828
|
+
ticketInFlight(recordId) {
|
|
4829
|
+
const file = this.path(`.${_State.safe(`crew-ticket-${recordId}`)}.lock`);
|
|
5439
4830
|
try {
|
|
5440
|
-
const
|
|
5441
|
-
if (
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
}
|
|
4831
|
+
const pid = Number.parseInt(readFileSync11(file, "utf8").trim(), 10);
|
|
4832
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
4833
|
+
process.kill(pid, 0);
|
|
4834
|
+
return true;
|
|
5445
4835
|
} catch {
|
|
4836
|
+
return false;
|
|
5446
4837
|
}
|
|
5447
4838
|
}
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
}
|
|
5484
|
-
|
|
4839
|
+
/**
|
|
4840
|
+
* How many consecutive cycles the release phase has refused to deploy.
|
|
4841
|
+
*
|
|
4842
|
+
* Refusing is correct — a dirty tree or a non-main checkout must never be
|
|
4843
|
+
* released — but it used to be SILENT, and eight commits once sat unreleased
|
|
4844
|
+
* behind a single untracked file with nothing saying so (ISSUE-174). One
|
|
4845
|
+
* blocked cycle is normal; the count is what makes a persistent one loud.
|
|
4846
|
+
*/
|
|
4847
|
+
/**
|
|
4848
|
+
* Release bookkeeping, scoped to one route.
|
|
4849
|
+
*
|
|
4850
|
+
* Scoped because a ship serves several boards and releases each repo
|
|
4851
|
+
* independently (ISSUE-338). These were single global files, so a deploy
|
|
4852
|
+
* failure on one route suppressed releases on every other one, and a
|
|
4853
|
+
* release blocked in one repo counted cycles for all of them — a dirty tree
|
|
4854
|
+
* in one checkout would have silently held up an unrelated project.
|
|
4855
|
+
*/
|
|
4856
|
+
release(route) {
|
|
4857
|
+
const suffix = _State.safe(route);
|
|
4858
|
+
const blocked = `.release-blocked-${suffix}`;
|
|
4859
|
+
const failed = `.deploy-failed-sha-${suffix}`;
|
|
4860
|
+
const gateFailed = `.test-gate-failed-${suffix}`;
|
|
4861
|
+
const gateReported = `.test-gate-reported-${suffix}`;
|
|
4862
|
+
const heldReported = `.release-held-${suffix}`;
|
|
4863
|
+
const read = (f) => {
|
|
4864
|
+
try {
|
|
4865
|
+
return readFileSync11(this.path(f), "utf8").trim() || null;
|
|
4866
|
+
} catch {
|
|
4867
|
+
return null;
|
|
4868
|
+
}
|
|
4869
|
+
};
|
|
4870
|
+
return {
|
|
4871
|
+
blockedCount: () => Number.parseInt(read(blocked) ?? "", 10) || 0,
|
|
4872
|
+
noteBlocked: () => {
|
|
4873
|
+
const n = (Number.parseInt(read(blocked) ?? "", 10) || 0) + 1;
|
|
4874
|
+
writeFileSync7(this.path(blocked), `${n}
|
|
4875
|
+
`);
|
|
4876
|
+
return n;
|
|
4877
|
+
},
|
|
4878
|
+
clearBlocked: () => {
|
|
4879
|
+
rmSync6(this.path(blocked), { force: true });
|
|
4880
|
+
},
|
|
4881
|
+
/**
|
|
4882
|
+
* The commit a deploy last failed on.
|
|
4883
|
+
*
|
|
4884
|
+
* Without this the crew retries a broken deploy on every cycle — every
|
|
4885
|
+
* two minutes, indefinitely — hammering the target and burying the
|
|
4886
|
+
* original failure under identical ones. A failed commit is retried
|
|
4887
|
+
* only when a NEW commit lands, or when an operator forces it by hand.
|
|
4888
|
+
*/
|
|
4889
|
+
deployFailedSha: () => read(failed),
|
|
4890
|
+
noteDeployFailed: (sha) => {
|
|
4891
|
+
writeFileSync7(this.path(failed), `${sha}
|
|
4892
|
+
`);
|
|
4893
|
+
},
|
|
4894
|
+
clearDeployFailed: () => {
|
|
4895
|
+
rmSync6(this.path(failed), { force: true });
|
|
4896
|
+
},
|
|
4897
|
+
/**
|
|
4898
|
+
* Consecutive test-gate failures on the same head (CREW-1368). A failure
|
|
4899
|
+
* on a new head restarts the count. Held on disk because one process is
|
|
4900
|
+
* one cycle, and "failed three cycles running" only exists across them.
|
|
4901
|
+
*/
|
|
4902
|
+
noteTestGateFailed: (sha) => {
|
|
4903
|
+
const [prev, n] = (read(gateFailed) ?? "").split(" ");
|
|
4904
|
+
const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
|
|
4905
|
+
writeFileSync7(this.path(gateFailed), `${sha} ${next}
|
|
4906
|
+
`);
|
|
4907
|
+
return next;
|
|
4908
|
+
},
|
|
4909
|
+
clearTestGateFailed: () => {
|
|
4910
|
+
rmSync6(this.path(gateFailed), { force: true });
|
|
4911
|
+
},
|
|
4912
|
+
/** Whether the board was already told about this head's red gate. */
|
|
4913
|
+
heldReported: () => {
|
|
4914
|
+
try {
|
|
4915
|
+
return readFileSync11(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
|
|
4916
|
+
} catch {
|
|
4917
|
+
return null;
|
|
4918
|
+
}
|
|
4919
|
+
},
|
|
4920
|
+
noteHeldReported: (signature) => {
|
|
4921
|
+
writeFileSync7(this.path(heldReported), `${signature}
|
|
4922
|
+
`);
|
|
4923
|
+
},
|
|
4924
|
+
clearHeldReported: () => {
|
|
4925
|
+
rmSync6(this.path(heldReported), { force: true });
|
|
4926
|
+
},
|
|
4927
|
+
testGateReported: (sha) => read(gateReported) === sha,
|
|
4928
|
+
noteTestGateReported: (sha) => {
|
|
4929
|
+
writeFileSync7(this.path(gateReported), `${sha}
|
|
4930
|
+
`);
|
|
4931
|
+
}
|
|
4932
|
+
};
|
|
4933
|
+
}
|
|
4934
|
+
/**
|
|
4935
|
+
* Starvation bookkeeping, scoped to one route (ISSUE-382).
|
|
4936
|
+
*
|
|
4937
|
+
* `decideFleet` already renders the winner and each role's rank; what it
|
|
4938
|
+
* throws away is the other side — a route that had work and did not
|
|
4939
|
+
* win, and the same ticket sitting at the front of that route's queue
|
|
4940
|
+
* cycle after cycle. This is state, not memory: a single process is one
|
|
4941
|
+
* cycle, so "how long has this been waiting" only exists if it survives
|
|
4942
|
+
* between them.
|
|
4943
|
+
*/
|
|
4944
|
+
fairness(route) {
|
|
4945
|
+
const suffix = _State.safe(route);
|
|
4946
|
+
const streakFile = `.fairness-streak-${suffix}`;
|
|
4947
|
+
const ticketFile = `.fairness-ticket-${suffix}`;
|
|
4948
|
+
const sinceFile = `.fairness-since-${suffix}`;
|
|
4949
|
+
const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
|
|
4950
|
+
const read = (f) => {
|
|
4951
|
+
try {
|
|
4952
|
+
return readFileSync11(this.path(f), "utf8").trim() || null;
|
|
4953
|
+
} catch {
|
|
4954
|
+
return null;
|
|
4955
|
+
}
|
|
4956
|
+
};
|
|
4957
|
+
const clear2 = () => {
|
|
4958
|
+
for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync6(this.path(f), { force: true });
|
|
4959
|
+
};
|
|
4960
|
+
return {
|
|
4961
|
+
/** Consecutive cycles this route had actionable work and did not win. */
|
|
4962
|
+
streak: () => Number.parseInt(read(streakFile) ?? "", 10) || 0,
|
|
4963
|
+
/**
|
|
4964
|
+
* The ticket sitting at the front of this route's queue, how long
|
|
4965
|
+
* it has been there and its own consecutive-passed-over count — or
|
|
4966
|
+
* undefined if nothing is waiting.
|
|
4967
|
+
*/
|
|
4968
|
+
waiting: () => {
|
|
4969
|
+
const ticket = read(ticketFile);
|
|
4970
|
+
const since2 = read(sinceFile);
|
|
4971
|
+
if (!ticket || !since2) return void 0;
|
|
4972
|
+
return { ticket, since: since2, streak: Number.parseInt(read(ticketStreakFile) ?? "", 10) || 0 };
|
|
4973
|
+
},
|
|
4974
|
+
/**
|
|
4975
|
+
* Record what one cycle saw for this route: how many tickets were
|
|
4976
|
+
* actionable, which one was most urgent, and whether this route
|
|
4977
|
+
* won. Winning or running dry both reset the streak — there is nothing
|
|
4978
|
+
* left being passed over.
|
|
4979
|
+
*/
|
|
4980
|
+
record: (actionable, topTicket, won) => {
|
|
4981
|
+
if (won || actionable === 0) {
|
|
4982
|
+
clear2();
|
|
4983
|
+
return;
|
|
4984
|
+
}
|
|
4985
|
+
writeFileSync7(this.path(streakFile), `${this.readInt(streakFile) + 1}
|
|
4986
|
+
`);
|
|
4987
|
+
const prior = read(ticketFile);
|
|
4988
|
+
if (topTicket && topTicket === prior) {
|
|
4989
|
+
writeFileSync7(this.path(ticketStreakFile), `${this.readInt(ticketStreakFile) + 1}
|
|
4990
|
+
`);
|
|
4991
|
+
} else if (topTicket) {
|
|
4992
|
+
writeFileSync7(this.path(ticketFile), `${topTicket}
|
|
4993
|
+
`);
|
|
4994
|
+
writeFileSync7(this.path(sinceFile), `${(/* @__PURE__ */ new Date()).toISOString()}
|
|
4995
|
+
`);
|
|
4996
|
+
writeFileSync7(this.path(ticketStreakFile), "1\n");
|
|
4997
|
+
}
|
|
4998
|
+
}
|
|
4999
|
+
};
|
|
5000
|
+
}
|
|
5001
|
+
/**
|
|
5002
|
+
* The previous cycle's "needs a person" set, scoped to one route
|
|
5003
|
+
* (ISSUE-928) — `{ ticketId: [reason, ...] }`. `poll.ts` reads it once
|
|
5004
|
+
* per cycle to compute transitions and the caller (`cli.ts`'s run path,
|
|
5005
|
+
* `fleet.ts`'s per-route loop) persists the new set afterward, beside the
|
|
5006
|
+
* existing `fairness(route).record(...)` call — never inside `decideCycle`
|
|
5007
|
+
* itself, which does no writes. A corrupt or missing file reads as empty,
|
|
5008
|
+
* which is also deliberately what a fresh install sees: everything
|
|
5009
|
+
* currently needing a person emits once, rather than being silently
|
|
5010
|
+
* skipped as though already notified.
|
|
5011
|
+
*/
|
|
5012
|
+
attention(route) {
|
|
5013
|
+
const file = `.attention-${_State.safe(route)}.json`;
|
|
5014
|
+
return {
|
|
5015
|
+
previous: () => {
|
|
5016
|
+
try {
|
|
5017
|
+
const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
|
|
5018
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
5019
|
+
} catch {
|
|
5020
|
+
return {};
|
|
5021
|
+
}
|
|
5022
|
+
},
|
|
5023
|
+
persist: (next) => {
|
|
5024
|
+
writeFileSync7(this.path(file), JSON.stringify(next));
|
|
5025
|
+
}
|
|
5026
|
+
};
|
|
5027
|
+
}
|
|
5028
|
+
/**
|
|
5029
|
+
* The previous cycle's unroutable ticket ids for one route (CREW-2548): open
|
|
5030
|
+
* tickets with no repository that are not exempt. `poll.ts` diffs against it
|
|
5031
|
+
* so each ticket warns once when it becomes unroutable, not every cycle; the
|
|
5032
|
+
* caller persists the new set beside `attention(route).persist(...)`. A
|
|
5033
|
+
* missing or corrupt file reads as empty, so a fresh install warns once.
|
|
5034
|
+
*/
|
|
5035
|
+
unroutable(route) {
|
|
5036
|
+
const file = `.unroutable-${_State.safe(route)}.json`;
|
|
5037
|
+
return {
|
|
5038
|
+
previous: () => {
|
|
5039
|
+
try {
|
|
5040
|
+
const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
|
|
5041
|
+
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
|
|
5042
|
+
} catch {
|
|
5043
|
+
return [];
|
|
5044
|
+
}
|
|
5045
|
+
},
|
|
5046
|
+
persist: (next) => {
|
|
5047
|
+
writeFileSync7(this.path(file), JSON.stringify(next));
|
|
5048
|
+
}
|
|
5049
|
+
};
|
|
5050
|
+
}
|
|
5051
|
+
/**
|
|
5052
|
+
* Open ship-level attention items (CREW-1373), ship-wide rather than
|
|
5053
|
+
* per-route: a parked role or a missing hook command is about this
|
|
5054
|
+
* machine. A corrupt or missing file reads as empty.
|
|
5055
|
+
*/
|
|
5056
|
+
shipAttention() {
|
|
5057
|
+
const file = ".ship-attention.json";
|
|
5058
|
+
return {
|
|
5059
|
+
previous: () => {
|
|
5060
|
+
try {
|
|
5061
|
+
const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
|
|
5062
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5063
|
+
} catch {
|
|
5064
|
+
return [];
|
|
5065
|
+
}
|
|
5066
|
+
},
|
|
5067
|
+
persist: (next) => {
|
|
5068
|
+
writeFileSync7(this.path(file), JSON.stringify(next));
|
|
5069
|
+
}
|
|
5070
|
+
};
|
|
5071
|
+
}
|
|
5072
|
+
/**
|
|
5073
|
+
* When the release phase last ran a cycle (CREW-1373), as the mtime of a
|
|
5074
|
+
* marker file. `touch()` is called at the start and the end of every cycle
|
|
5075
|
+
* so a long deploy does not read as a stopped timer.
|
|
5076
|
+
*/
|
|
5077
|
+
releaseHeartbeat() {
|
|
5078
|
+
const file = ".release-last-run";
|
|
5079
|
+
return {
|
|
5080
|
+
touch: () => {
|
|
5081
|
+
writeFileSync7(this.path(file), (/* @__PURE__ */ new Date()).toISOString());
|
|
5082
|
+
},
|
|
5083
|
+
at: () => {
|
|
5084
|
+
try {
|
|
5085
|
+
return statSync4(this.path(file)).mtimeMs;
|
|
5086
|
+
} catch {
|
|
5087
|
+
return null;
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
};
|
|
5091
|
+
}
|
|
5092
|
+
readInt(file) {
|
|
5093
|
+
try {
|
|
5094
|
+
return Number.parseInt(readFileSync11(this.path(file), "utf8").trim(), 10) || 0;
|
|
5095
|
+
} catch {
|
|
5096
|
+
return 0;
|
|
5097
|
+
}
|
|
5098
|
+
}
|
|
5099
|
+
};
|
|
5100
|
+
|
|
5101
|
+
// src/environment.ts
|
|
5102
|
+
import { basename as basename5 } from "path";
|
|
5103
|
+
var API_KEY_VAR = "CREW_API_KEY";
|
|
5104
|
+
function authorshipBullets(a, contract) {
|
|
5105
|
+
if (!a) return [];
|
|
5106
|
+
return [
|
|
5107
|
+
...a.issueAuthor ? [`- \`${contract.columns.author}\`: your own Crew row id, from "Your crew" above \u2014 who filed it`] : [],
|
|
5108
|
+
...a.issueShip && a.shipId ? [`- \`${contract.columns.ship}\`: \`${a.shipId}\` \u2014 this ship, where it was filed from`] : [],
|
|
5109
|
+
...a.commentShip && a.shipId ? [`- \`${contract.comments.ship}\` on every comment you post: \`${a.shipId}\` \u2014 this ship`] : []
|
|
5110
|
+
];
|
|
5111
|
+
}
|
|
5112
|
+
var HOOK_PURPOSE = [
|
|
5113
|
+
["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
|
|
5114
|
+
["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
|
|
5115
|
+
["ports", "this worktree's port assignments; prints KEY=value overrides to export"],
|
|
5116
|
+
["handoff", "leave the operator able to open what you built; put its output in your progress comment"],
|
|
5117
|
+
["test", "run the suite"],
|
|
5118
|
+
["build", "build it"],
|
|
5119
|
+
["ci_status", "after you push and before you hand off: exit 0 means CI is green for your branch; on failure fix and push again, and say in your progress comment if it is still pending"]
|
|
5120
|
+
];
|
|
5121
|
+
function hookTable(repo) {
|
|
5122
|
+
const declared = HOOK_PURPOSE.filter(([name]) => repo.hooks[name]);
|
|
5123
|
+
if (declared.length === 0) {
|
|
5124
|
+
return "This repository declares no hooks. Do not guess commands for it \u2014 say so in your progress comment and stop.\n";
|
|
5125
|
+
}
|
|
5126
|
+
const inline = (cmd) => cmd.includes("\n") ? "_(below)_" : `\`${cmd}\``;
|
|
5127
|
+
const blocks = declared.filter(([name]) => repo.hooks[name].includes("\n")).map(([name]) => [`### \`${name}\``, "", "```sh", repo.hooks[name].trimEnd(), "```", ""].join("\n"));
|
|
5128
|
+
const undeclared = HOOK_PURPOSE.filter(([n]) => !repo.hooks[n]);
|
|
5129
|
+
return [
|
|
5130
|
+
"| hook | what it is for | what it runs |",
|
|
5131
|
+
"| --- | --- | --- |",
|
|
5132
|
+
...declared.map(([name, purpose]) => `| \`${name}\` | ${purpose} | ${inline(repo.hooks[name])} |`),
|
|
5133
|
+
"",
|
|
5134
|
+
"Run these **verbatim**, from the worktree. They come from the repository's own",
|
|
5135
|
+
"configuration, which is the only thing that knows them. A command you invented",
|
|
5136
|
+
"yourself is wrong even when it works, because the next repository will differ.",
|
|
5137
|
+
"",
|
|
5138
|
+
...blocks,
|
|
5139
|
+
...undeclared.length ? [`Not declared here, so there is nothing to run for them: ${undeclared.map(([n]) => `\`${n}\``).join(", ")}.`, ""] : []
|
|
5140
|
+
].join("\n");
|
|
5141
|
+
}
|
|
5142
|
+
function repoPrefix(repo) {
|
|
5143
|
+
return repo.provenance["worktrees.prefix"] !== "default" ? repo.worktrees.prefix.replace(/-$/, "") : void 0;
|
|
5144
|
+
}
|
|
5145
|
+
function branchExample(repo, key) {
|
|
5146
|
+
const prefix = repoPrefix(repo);
|
|
5147
|
+
const template = effectiveBranchTemplate(repo, prefix);
|
|
5148
|
+
return renderBranchName(template, { key, title: "Fix the widget", role: "dev", prefix, lowercase: repo.branch.lowercase });
|
|
5149
|
+
}
|
|
5150
|
+
function repoSection(r, key, heading) {
|
|
5151
|
+
const repo = r.config;
|
|
5152
|
+
const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
|
|
5153
|
+
const branchName = branchExample(repo, key);
|
|
5154
|
+
const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
|
|
5155
|
+
const worktreeName = effectiveWorktreeDirName(
|
|
5156
|
+
repo,
|
|
5157
|
+
r.dir,
|
|
5158
|
+
branchName,
|
|
5159
|
+
key.replace(/^\D+/, ""),
|
|
5160
|
+
// The example ticket has no tag of its own, so `{issue}` is the key.
|
|
5161
|
+
(template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
|
|
5162
|
+
);
|
|
5163
|
+
const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
|
|
5164
|
+
const lines = [
|
|
5165
|
+
"",
|
|
5166
|
+
heading,
|
|
5167
|
+
"",
|
|
5168
|
+
`- Cut it from \`${repo.branch.base}\`, beside the checkout, at`,
|
|
5169
|
+
` \`../${worktreePattern}\` \u2014 for ${key} that is \`../${worktreeName}\`.`,
|
|
5170
|
+
`- Name the branch the way this repository names branches: \`${branchTemplate}\``,
|
|
5171
|
+
` \u2014 for ${key} that is \`${branchName}\`.`,
|
|
5172
|
+
"- Never work in the main checkout. It is the operator's, and the release phase",
|
|
5173
|
+
" uses it.",
|
|
5174
|
+
"",
|
|
5175
|
+
`${key} above is a placeholder, not a real ticket: this section is rendered`,
|
|
5176
|
+
"before any ticket is chosen, so it cannot know a specific ticket's own",
|
|
5177
|
+
"project prefix. Once you have a ticket, its digest row's `branch` and",
|
|
5178
|
+
"`worktree` columns are the real, per-ticket values \u2014 use those verbatim",
|
|
5179
|
+
"over this section's worked example if the two ever disagree."
|
|
5180
|
+
];
|
|
5181
|
+
if (repo.worktrees.copy.length) {
|
|
5182
|
+
lines.push(
|
|
5183
|
+
"",
|
|
5184
|
+
"A worktree is a clean checkout, so it is missing exactly the files git ignores.",
|
|
5185
|
+
"Copy these across from the main checkout before running anything:",
|
|
5186
|
+
"",
|
|
5187
|
+
...repo.worktrees.copy.map((c) => `- \`${c}\``)
|
|
5188
|
+
);
|
|
5189
|
+
}
|
|
5190
|
+
if (repo.docs.designGuide) {
|
|
5191
|
+
lines.push(
|
|
5192
|
+
"",
|
|
5193
|
+
"## This project's design brief",
|
|
5194
|
+
"",
|
|
5195
|
+
`\`${repo.docs.designGuide}\`, in the repository. Read it from the primary`,
|
|
5196
|
+
"checkout before you `cd` into any worktree \u2014 a worktree cut from the base",
|
|
5197
|
+
"branch only has it once it has been committed there."
|
|
5198
|
+
);
|
|
5199
|
+
}
|
|
5200
|
+
if (repo.docs.triagePolicy) {
|
|
5201
|
+
lines.push(
|
|
5202
|
+
"",
|
|
5203
|
+
"## This project's triage policy",
|
|
5204
|
+
"",
|
|
5205
|
+
`\`${repo.docs.triagePolicy}\`, in the repository. Where it and any brief`,
|
|
5206
|
+
"disagree about what a status means or when a ticket may be accepted, the",
|
|
5207
|
+
"policy document wins: it is the project's own decision, and the brief is",
|
|
5208
|
+
"shared with projects that decided differently."
|
|
5209
|
+
);
|
|
5210
|
+
}
|
|
5211
|
+
return lines;
|
|
5212
|
+
}
|
|
5213
|
+
function renderEnvironment(i) {
|
|
5214
|
+
const { route, contract } = i;
|
|
5215
|
+
const repos = i.repos;
|
|
5216
|
+
const only = repos.length === 1 ? repos[0] : void 0;
|
|
5217
|
+
const models = route.resolved?.models;
|
|
5218
|
+
const epicsModelId = route.resolved?.epicsModelId;
|
|
5219
|
+
const mediaLibraryModelId = i.attachments?.mediaLibraryModelId ?? null;
|
|
5220
|
+
const key = "ISSUE-000";
|
|
5221
|
+
const lines = [
|
|
5222
|
+
"# Your environment",
|
|
5223
|
+
"",
|
|
5224
|
+
"Supplied by the crew for THIS run. The brief below is shared by every project",
|
|
5225
|
+
"the crew works on and deliberately names nothing specific; this section is where",
|
|
5226
|
+
"those names come from. Where the two appear to disagree, this section wins.",
|
|
5227
|
+
"",
|
|
5228
|
+
"## The board",
|
|
5229
|
+
"",
|
|
5230
|
+
`- Base URL: \`${route.baseUrl}\``,
|
|
5231
|
+
`- Your key is in the \`${API_KEY_VAR}\` environment variable. Send it as`,
|
|
5232
|
+
` \`Authorization: Bearer $${API_KEY_VAR}\`. **Do not read it out of any file**,`,
|
|
5233
|
+
" and never write it into a comment, a commit or a log line.",
|
|
5234
|
+
`- Send \`User-Agent: ${i.userAgent}\` on every request.`
|
|
5235
|
+
];
|
|
5236
|
+
if (models) {
|
|
5237
|
+
lines.push(
|
|
5238
|
+
"",
|
|
5239
|
+
"Tables, by id \u2014 use these directly rather than listing the workspace to find them:",
|
|
5240
|
+
"",
|
|
5241
|
+
`- Issues: \`${models.issues}\``,
|
|
5242
|
+
`- Comments: \`${models.comments}\``,
|
|
5243
|
+
`- Crew: \`${models.crew}\``,
|
|
5244
|
+
...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : [],
|
|
5245
|
+
...mediaLibraryModelId ? [`- Media Library: \`${mediaLibraryModelId}\` (what a ticket's \`${contract.columns.attachments}\` ids point into)`] : []
|
|
5246
|
+
);
|
|
5247
|
+
}
|
|
5248
|
+
const workspaceId = route.resolved?.workspaceId;
|
|
5249
|
+
if (workspaceId) {
|
|
5250
|
+
const base = route.baseUrl.replace(/\/+$/, "");
|
|
5251
|
+
lines.push(
|
|
5252
|
+
"",
|
|
5253
|
+
`- Workspace id: \`${workspaceId}\``,
|
|
5254
|
+
"",
|
|
5255
|
+
"## Attaching a file to a comment",
|
|
5256
|
+
"",
|
|
5257
|
+
"A screenshot or mockup goes on a comment's `attachments` field, which holds",
|
|
5258
|
+
"Media Library record ids, not URLs. Upload the file first, then put the",
|
|
5259
|
+
"returned `id` in the comment's `attachments` array:",
|
|
5260
|
+
"",
|
|
5261
|
+
"```sh",
|
|
5262
|
+
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
5263
|
+
` -X POST "${base}/api/workspaces/${workspaceId}/files" \\`,
|
|
5264
|
+
' -F "file=@shot.png;type=image/png" -F targetMediaLibrary=true',
|
|
5265
|
+
"```",
|
|
5266
|
+
"",
|
|
5267
|
+
"The response is JSON with `id` and `storageKey`. `targetMediaLibrary=true`",
|
|
5268
|
+
"means no field type id has to be looked up. If the upload fails, post the HTTP",
|
|
5269
|
+
"status and response body in your comment \u2014 do not write that uploads are",
|
|
5270
|
+
"impossible."
|
|
5271
|
+
);
|
|
5272
|
+
if (mediaLibraryModelId) {
|
|
5273
|
+
lines.push(
|
|
5274
|
+
"",
|
|
5275
|
+
"## Opening a ticket's attachments",
|
|
5276
|
+
"",
|
|
5277
|
+
`A ticket's or a comment's \`${contract.columns.attachments}\` value is a list of Media Library`,
|
|
5278
|
+
"**record ids** \u2014 not file ids and not URLs. `files/<id>`, `files/<id>/download` and",
|
|
5279
|
+
"`media-library/<id>/...` do not exist for such an id and return 404. For each id,",
|
|
5280
|
+
"read the record, then fetch the file by its storage key:",
|
|
5281
|
+
"",
|
|
5282
|
+
"```sh",
|
|
5283
|
+
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
5284
|
+
` "${base}/api/workspaces/${workspaceId}/data-models/${mediaLibraryModelId}/records/<attachment id>"`,
|
|
5285
|
+
'# \u2192 JSON with `storage_key` ("<uuid>/<filename>") and `mime_type`',
|
|
5286
|
+
`curl -s -o <scratch dir>/<filename> -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
5287
|
+
` "${base}/api/workspaces/${workspaceId}/files/<storage_key>"`,
|
|
5288
|
+
"```",
|
|
5289
|
+
"",
|
|
5290
|
+
"The MCP forms are `records_controller_find_one` (`dataModelId` = the Media Library",
|
|
5291
|
+
"id above) and `files_controller_download` (`uuid` and `filename` = the two halves",
|
|
5292
|
+
"of the storage key). Save the file outside the repository and open it; where it",
|
|
5293
|
+
"disagrees with the description, the picture is the reproduction."
|
|
5294
|
+
);
|
|
5295
|
+
}
|
|
5296
|
+
}
|
|
5297
|
+
if (i.shipName) {
|
|
5298
|
+
lines.push(
|
|
5299
|
+
"",
|
|
5300
|
+
"## Ships",
|
|
5301
|
+
"",
|
|
5302
|
+
`This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
|
|
5303
|
+
"another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
|
|
5304
|
+
);
|
|
5305
|
+
}
|
|
5306
|
+
const seatIds = Object.entries(route.resolved?.seats ?? {}).filter(([, id]) => !!id);
|
|
5307
|
+
if (seatIds.length > 0) {
|
|
5308
|
+
lines.push(
|
|
5309
|
+
"",
|
|
5310
|
+
"## Seats on this ship",
|
|
5311
|
+
"",
|
|
5312
|
+
"The Crew row id of each role's seat on this ship, to write verbatim where a hand-off",
|
|
5313
|
+
"assigns the next lane (a ticket whose digest `mode` is `hybrid`):",
|
|
5314
|
+
"",
|
|
5315
|
+
...seatIds.map(([role, id]) => `- ${role}: \`${id}\``)
|
|
5316
|
+
);
|
|
5317
|
+
}
|
|
5318
|
+
lines.push(
|
|
5319
|
+
"",
|
|
5320
|
+
"## What the columns are called here",
|
|
5321
|
+
"",
|
|
5322
|
+
"Every workspace names these itself. Use these names, not the ones you would",
|
|
5323
|
+
"guess, and not the ones another project used.",
|
|
5324
|
+
"",
|
|
5325
|
+
"| meaning | column |",
|
|
5326
|
+
"| --- | --- |",
|
|
5327
|
+
`| ticket key | \`${contract.columns.key}\` |`,
|
|
5328
|
+
`| status | \`${contract.columns.status}\` |`,
|
|
5329
|
+
`| assignee | \`${contract.columns.assignee}\` |`,
|
|
5330
|
+
`| severity | \`${contract.columns.severity}\` |`,
|
|
5331
|
+
`| priority | \`${contract.columns.priority}\` |`,
|
|
5332
|
+
`| blocked by | \`${contract.columns.blockedBy}\` |`,
|
|
5333
|
+
`| needs design | \`${contract.columns.needsDesign}\` |`,
|
|
5334
|
+
`| project | \`${contract.columns.slice}\` |`,
|
|
5335
|
+
`| repo | \`${contract.columns.repo}\` |`,
|
|
5336
|
+
...epicsModelId ? [`| epic | \`${contract.columns.epic}\` |`] : [],
|
|
5337
|
+
"",
|
|
5338
|
+
...epicsModelId ? [
|
|
5339
|
+
"An epic is a body of work a ticket belongs to. Set it when a ticket clearly",
|
|
5340
|
+
"fits an existing epic; proposing a new one is a taxonomy decision \u2014 leave it",
|
|
5341
|
+
"to a person rather than inventing one yourself.",
|
|
5342
|
+
"",
|
|
5343
|
+
"An epic's own status is kept by the crew runner, from its tickets: done once",
|
|
5344
|
+
"every one of them is closed, in progress while any is being worked. Do not",
|
|
5345
|
+
"set it yourself.",
|
|
5346
|
+
""
|
|
5347
|
+
] : [],
|
|
5348
|
+
"Statuses that matter to you:",
|
|
5349
|
+
"",
|
|
5350
|
+
`- Work you may pick up: \`${contract.statuses.approved}\``,
|
|
5351
|
+
`- While you are working it: \`${contract.statuses.building}\``,
|
|
5352
|
+
`- When you hand it on to be checked: \`${contract.statuses.handoff}\``,
|
|
5353
|
+
`- Being checked: \`${contract.statuses.verifying}\``,
|
|
5354
|
+
`- Waiting on a person to answer something: \`${contract.statuses.needsHuman}\``,
|
|
5355
|
+
`- Parked on an unresolved dependency, set by the crew and not by you: \`${contract.statuses.parked}\``,
|
|
5356
|
+
"",
|
|
5357
|
+
`\`${contract.statuses.verified}\` and the closed statuses are **never yours to set**.`
|
|
5358
|
+
);
|
|
5359
|
+
const areaId = route.resolved?.areaId;
|
|
5360
|
+
if (areaId || repos.some((r) => r.id)) {
|
|
5361
|
+
lines.push(
|
|
5362
|
+
"",
|
|
5363
|
+
"## Filing a new ticket",
|
|
5364
|
+
"",
|
|
5365
|
+
"Something broken that is out of scope for the ticket you are working is not",
|
|
5366
|
+
"yours to fix inline \u2014 file it as its own new ticket instead, and keep working",
|
|
5367
|
+
"the one you were on. A ticket filed without its project and repo sits unrouted",
|
|
5368
|
+
"until a person notices and fixes it by hand, so always set:",
|
|
5369
|
+
"",
|
|
5370
|
+
...areaId ? [`- \`${contract.columns.slice}\`: \`${areaId}\` (this route's project, for every ticket you file)`] : [],
|
|
5371
|
+
...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
|
|
5372
|
+
(r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
|
|
5373
|
+
),
|
|
5374
|
+
...authorshipBullets(i.authorship, contract),
|
|
5375
|
+
"",
|
|
5376
|
+
...i.sourceTicket ? [
|
|
5377
|
+
`Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
|
|
5378
|
+
"ticket's description. Only put it in the new ticket's `blockedBy` if your own ticket",
|
|
5379
|
+
"genuinely cannot proceed without the new one being fixed first; merely having noticed it",
|
|
5380
|
+
"nearby is not a dependency.",
|
|
5381
|
+
""
|
|
5382
|
+
] : []
|
|
5383
|
+
);
|
|
5384
|
+
}
|
|
5385
|
+
if (only) {
|
|
5386
|
+
lines.push(...repoSection(only, key, "## Your worktree"));
|
|
5387
|
+
} else {
|
|
5388
|
+
lines.push(
|
|
5389
|
+
"",
|
|
5390
|
+
"## The repositories this board covers",
|
|
5391
|
+
"",
|
|
5392
|
+
"This board spans several repositories, and they do not share a branch name,",
|
|
5393
|
+
"a worktree location or a test command. **The `repo` column of your queue",
|
|
5394
|
+
"says which one your ticket belongs to** \u2014 read the section for THAT one and",
|
|
5395
|
+
"ignore the others. A ticket whose repo is not listed here has no checkout on",
|
|
5396
|
+
"this machine and is not yours to work."
|
|
5397
|
+
);
|
|
5398
|
+
for (const r of repos) {
|
|
5399
|
+
lines.push(...repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
|
|
5400
|
+
lines.push("", `Hooks for ${r.name} \u2014 the only commands you should run here:`, "", hookTable(r.config));
|
|
5485
5401
|
}
|
|
5486
5402
|
}
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
|
|
5490
|
-
if (!failed) return null;
|
|
5491
|
-
const [ticketRecordId, s] = failed;
|
|
5492
|
-
return {
|
|
5493
|
-
ticketRecordId,
|
|
5494
|
-
patch: s.patch,
|
|
5495
|
-
...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
|
|
5496
|
-
...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
|
|
5497
|
-
finalText: this.lastText || this.resultLine
|
|
5498
|
-
};
|
|
5403
|
+
if (i.repoDir) {
|
|
5404
|
+
lines.push("", `This ticket's work happens in \`${i.repoDir}\`.`);
|
|
5499
5405
|
}
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
import { existsSync as existsSync15, mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
|
|
5504
|
-
import { join as join16 } from "path";
|
|
5505
|
-
|
|
5506
|
-
// src/ticket-branch.ts
|
|
5507
|
-
import { basename as basename6 } from "path";
|
|
5508
|
-
|
|
5509
|
-
// src/tracker.ts
|
|
5510
|
-
import { hostname } from "os";
|
|
5511
|
-
import { TablationClient } from "@tablation/client";
|
|
5406
|
+
if (only) lines.push("", "## Hooks \u2014 the only commands you should run", "", hookTable(only.config));
|
|
5407
|
+
return lines.join("\n");
|
|
5408
|
+
}
|
|
5512
5409
|
|
|
5513
|
-
// src/
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5410
|
+
// src/contract.ts
|
|
5411
|
+
var DEFAULT_CONTRACT = {
|
|
5412
|
+
columns: {
|
|
5413
|
+
key: "issue_id",
|
|
5414
|
+
tag: "issue_tag",
|
|
5415
|
+
title: "title",
|
|
5416
|
+
status: "status",
|
|
5417
|
+
assignee: "assignee_id",
|
|
5418
|
+
priority: "priority",
|
|
5419
|
+
severity: "severity",
|
|
5420
|
+
slice: "project_id",
|
|
5421
|
+
repo: "repo_id",
|
|
5422
|
+
parent: "parent_id",
|
|
5423
|
+
blockedBy: "blocked_by",
|
|
5424
|
+
updatedAt: "updated_at",
|
|
5425
|
+
needsDesign: "needs_design",
|
|
5426
|
+
attachments: "attachments",
|
|
5427
|
+
workMode: "work_mode",
|
|
5428
|
+
reportType: "report_type",
|
|
5429
|
+
epic: "epic_id",
|
|
5430
|
+
author: "filed_by_id",
|
|
5431
|
+
ship: "ship_id",
|
|
5432
|
+
heldBy: "held_by_ship_id",
|
|
5433
|
+
resolutionNote: "resolution_note",
|
|
5434
|
+
verifiedSha: "verified_sha",
|
|
5435
|
+
prRef: "pr_ref",
|
|
5436
|
+
externalId: "external_id",
|
|
5437
|
+
position: "position",
|
|
5438
|
+
agentRuns: "agent_runs",
|
|
5439
|
+
tokensIn: "tokens_in",
|
|
5440
|
+
tokensOut: "tokens_out",
|
|
5441
|
+
cacheReadTokens: "cache_read_tokens",
|
|
5442
|
+
cacheWriteTokens: "cache_write_tokens",
|
|
5443
|
+
costUsd: "cost_usd"
|
|
5444
|
+
},
|
|
5445
|
+
comments: {
|
|
5446
|
+
parent: "ticket_id",
|
|
5447
|
+
body: "body",
|
|
5448
|
+
author: "team_member_id",
|
|
5449
|
+
createdAt: "created_at",
|
|
5450
|
+
ship: "ship_id",
|
|
5451
|
+
publicReply: "is_public_reply",
|
|
5452
|
+
authorName: "author_name"
|
|
5453
|
+
},
|
|
5454
|
+
statuses: {
|
|
5455
|
+
open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
|
|
5456
|
+
resolved: [
|
|
5457
|
+
"verified",
|
|
5458
|
+
"closed_deployed",
|
|
5459
|
+
"closed_wont_fix",
|
|
5460
|
+
"closed_duplicate",
|
|
5461
|
+
"closed_completed",
|
|
5462
|
+
"closed_cant_reproduce"
|
|
5463
|
+
],
|
|
5464
|
+
approved: "accepted",
|
|
5465
|
+
parked: "blocked",
|
|
5466
|
+
building: "in_progress",
|
|
5467
|
+
handoff: "fixed",
|
|
5468
|
+
verifying: "qa",
|
|
5469
|
+
verified: "verified",
|
|
5470
|
+
deployed: "closed_deployed",
|
|
5471
|
+
needsHuman: "needs_info",
|
|
5472
|
+
reviewing: null,
|
|
5473
|
+
wontFix: "closed_wont_fix",
|
|
5474
|
+
draft: "draft"
|
|
5475
|
+
},
|
|
5476
|
+
epics: {
|
|
5477
|
+
statusColumn: "status",
|
|
5478
|
+
keyColumn: "epic_id",
|
|
5479
|
+
statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
|
|
5480
|
+
},
|
|
5481
|
+
priorityOrder: ["p0", "p1", "p2", "p3"],
|
|
5482
|
+
severityOrder: ["s1", "s2", "s3", "s4"],
|
|
5483
|
+
unknownPriorityRank: 2,
|
|
5484
|
+
coordinatingValue: "coordinating",
|
|
5485
|
+
manualWorkMode: "manual",
|
|
5486
|
+
hybridWorkMode: "hybrid",
|
|
5487
|
+
repoExemptReportTypes: ["coordinating", "question", "investigation"]
|
|
5488
|
+
};
|
|
5489
|
+
function closedStatuses(c) {
|
|
5490
|
+
return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
|
|
5491
|
+
}
|
|
5492
|
+
function resolveContract(override) {
|
|
5493
|
+
if (!override) return DEFAULT_CONTRACT;
|
|
5494
|
+
return {
|
|
5495
|
+
columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
|
|
5496
|
+
comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
|
|
5497
|
+
statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
|
|
5498
|
+
epics: {
|
|
5499
|
+
...DEFAULT_CONTRACT.epics,
|
|
5500
|
+
...override.epics,
|
|
5501
|
+
statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
|
|
5502
|
+
},
|
|
5503
|
+
priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
|
|
5504
|
+
severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
|
|
5505
|
+
unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
|
|
5506
|
+
coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
|
|
5507
|
+
manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
|
|
5508
|
+
hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
|
|
5509
|
+
// A workspace overriding `coordinatingValue` alone (renaming the rollup
|
|
5510
|
+
// marker) still gets that renamed value exempted here by default,
|
|
5511
|
+
// without also having to repeat it — only an explicit
|
|
5512
|
+
// `repoExemptReportTypes` overrides this derived default.
|
|
5513
|
+
repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
|
|
5514
|
+
};
|
|
5515
|
+
}
|
|
5516
|
+
function validateContract(c) {
|
|
5517
|
+
const problems = [];
|
|
5518
|
+
const open = new Set(c.statuses.open);
|
|
5519
|
+
const resolved = new Set(c.statuses.resolved);
|
|
5520
|
+
for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
|
|
5521
|
+
const v = c.statuses[role];
|
|
5522
|
+
if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
|
|
5540
5523
|
}
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
`);
|
|
5524
|
+
if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
|
|
5525
|
+
problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
|
|
5544
5526
|
}
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
*
|
|
5548
|
-
* Using the clock would silently skip any comment written between the fetch
|
|
5549
|
-
* and the write — a race that loses work rather than repeating it, which is
|
|
5550
|
-
* the wrong way round. Repeating a cycle is free; missing one is not.
|
|
5551
|
-
*/
|
|
5552
|
-
advanceWatermark(seen) {
|
|
5553
|
-
const stamps = seen.flatMap((s) => [s.created_at, s.updated_at]).filter((v) => typeof v === "string" && v.length > 0);
|
|
5554
|
-
const next = stamps.length ? stamps.reduce((a, b) => a > b ? a : b) : this.watermark();
|
|
5555
|
-
this.setWatermark(next);
|
|
5556
|
-
return next;
|
|
5527
|
+
if (!resolved.has(c.statuses.deployed)) {
|
|
5528
|
+
problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
|
|
5557
5529
|
}
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5530
|
+
if (resolved.has(c.statuses.handoff)) {
|
|
5531
|
+
problems.push(
|
|
5532
|
+
`statuses.handoff ("${c.statuses.handoff}") is listed as resolved \u2014 it means built-but-unchecked, so a ticket blocked by it is still blocked; calling it resolved would build on unverified work`
|
|
5533
|
+
);
|
|
5561
5534
|
}
|
|
5562
|
-
|
|
5563
|
-
|
|
5535
|
+
if (!resolved.has(c.statuses.wontFix)) {
|
|
5536
|
+
problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
|
|
5564
5537
|
}
|
|
5565
|
-
|
|
5566
|
-
|
|
5538
|
+
if (c.statuses.reviewing && resolved.has(c.statuses.reviewing)) {
|
|
5539
|
+
problems.push(
|
|
5540
|
+
`statuses.reviewing ("${c.statuses.reviewing}") is listed as resolved \u2014 it is unmerged work under review, so a ticket blocked by it is still blocked`
|
|
5541
|
+
);
|
|
5567
5542
|
}
|
|
5568
|
-
|
|
5569
|
-
|
|
5543
|
+
if (resolved.has(c.statuses.approved)) {
|
|
5544
|
+
problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
|
|
5570
5545
|
}
|
|
5571
|
-
|
|
5572
|
-
|
|
5546
|
+
if (c.statuses.parked === c.statuses.approved) {
|
|
5547
|
+
problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
|
|
5573
5548
|
}
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
* while agents keep working.
|
|
5578
|
-
*/
|
|
5579
|
-
isReleasePaused() {
|
|
5580
|
-
return existsSync12(this.path(".release-paused"));
|
|
5549
|
+
const epicValues = Object.values(c.epics.statuses);
|
|
5550
|
+
if (new Set(epicValues).size !== epicValues.length) {
|
|
5551
|
+
problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
|
|
5581
5552
|
}
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5553
|
+
if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
|
|
5554
|
+
if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
|
|
5555
|
+
for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
|
|
5556
|
+
if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
|
|
5585
5557
|
}
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5558
|
+
return problems;
|
|
5559
|
+
}
|
|
5560
|
+
var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
|
|
5561
|
+
|
|
5562
|
+
// src/agent-log.ts
|
|
5563
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync7, readdirSync as readdirSync5, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
|
|
5564
|
+
import { join as join13 } from "path";
|
|
5565
|
+
|
|
5566
|
+
// src/pending-backoff.ts
|
|
5567
|
+
var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
|
|
5568
|
+
|
|
5569
|
+
// src/agent-log.ts
|
|
5570
|
+
var AgentLogUnreachableError = class extends Error {
|
|
5571
|
+
};
|
|
5572
|
+
var AgentLogHttpError = class extends Error {
|
|
5573
|
+
status;
|
|
5574
|
+
/** True when the refusal was of the Agent Log row itself (not a later cycle post): the tracker is not taking this seat's reports (CREW-2832). */
|
|
5575
|
+
row = false;
|
|
5576
|
+
constructor(message, status2) {
|
|
5577
|
+
super(message);
|
|
5578
|
+
this.status = status2;
|
|
5598
5579
|
}
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5580
|
+
};
|
|
5581
|
+
async function request(target, method, path, body) {
|
|
5582
|
+
const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
|
|
5583
|
+
const res = await fetch(url, {
|
|
5584
|
+
method,
|
|
5585
|
+
headers: {
|
|
5586
|
+
Authorization: `Bearer ${target.apiKey}`,
|
|
5587
|
+
"User-Agent": target.userAgent,
|
|
5588
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
5589
|
+
},
|
|
5590
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
5591
|
+
});
|
|
5592
|
+
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
5593
|
+
return await res.json();
|
|
5594
|
+
}
|
|
5595
|
+
var issueColumns = /* @__PURE__ */ new Map();
|
|
5596
|
+
function columnsOf(target, modelId) {
|
|
5597
|
+
const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
|
|
5598
|
+
let p = issueColumns.get(key);
|
|
5599
|
+
if (!p) {
|
|
5600
|
+
p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
|
|
5601
|
+
p.catch(() => issueColumns.delete(key));
|
|
5602
|
+
issueColumns.set(key, p);
|
|
5616
5603
|
}
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
}
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5604
|
+
return p;
|
|
5605
|
+
}
|
|
5606
|
+
async function findTicketRecord(target, t, ref) {
|
|
5607
|
+
const filters = {
|
|
5608
|
+
combinator: "OR",
|
|
5609
|
+
conditions: [
|
|
5610
|
+
{ columnName: t.keyColumn, operator: "EQ", value: ref },
|
|
5611
|
+
{ columnName: t.tagColumn, operator: "EQ", value: ref }
|
|
5612
|
+
]
|
|
5613
|
+
};
|
|
5614
|
+
const rows = await request(
|
|
5615
|
+
target,
|
|
5616
|
+
"GET",
|
|
5617
|
+
`/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
|
|
5618
|
+
);
|
|
5619
|
+
return rows.length === 1 ? rows[0].id : void 0;
|
|
5620
|
+
}
|
|
5621
|
+
function counterIncrements(run2, cols, have) {
|
|
5622
|
+
const figures = [
|
|
5623
|
+
[cols.agentRuns, 1],
|
|
5624
|
+
[cols.tokensIn, run2.tokensIn],
|
|
5625
|
+
[cols.tokensOut, run2.tokensOut],
|
|
5626
|
+
[cols.cacheReadTokens, run2.cacheReadTokens],
|
|
5627
|
+
[cols.cacheWriteTokens, run2.cacheWriteTokens],
|
|
5628
|
+
[cols.costUsd, run2.costUsd]
|
|
5629
|
+
];
|
|
5630
|
+
const out = {};
|
|
5631
|
+
for (const [column, value2] of figures) {
|
|
5632
|
+
if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
|
|
5642
5633
|
}
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5634
|
+
return out;
|
|
5635
|
+
}
|
|
5636
|
+
async function post(target, path, body) {
|
|
5637
|
+
const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
|
|
5638
|
+
const res = await fetch(url, {
|
|
5639
|
+
method: "POST",
|
|
5640
|
+
headers: {
|
|
5641
|
+
Authorization: `Bearer ${target.apiKey}`,
|
|
5642
|
+
// Cloudflare 403s default agents on this host — same header connect.ts sends.
|
|
5643
|
+
"User-Agent": target.userAgent,
|
|
5644
|
+
"Content-Type": "application/json"
|
|
5645
|
+
},
|
|
5646
|
+
body: JSON.stringify(body)
|
|
5647
|
+
});
|
|
5648
|
+
if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
|
|
5649
|
+
return await res.json();
|
|
5650
|
+
}
|
|
5651
|
+
async function reportAgentRun(target, run2) {
|
|
5652
|
+
let ticketRecordId;
|
|
5653
|
+
if (target.tickets && run2.ticketReference) {
|
|
5650
5654
|
try {
|
|
5651
|
-
|
|
5655
|
+
ticketRecordId = await findTicketRecord(target, target.tickets, run2.ticketReference);
|
|
5652
5656
|
} catch {
|
|
5653
|
-
return false;
|
|
5654
|
-
}
|
|
5655
|
-
for (const n of names) {
|
|
5656
|
-
if (!/^\.release-.+\.lock$/.test(n)) continue;
|
|
5657
|
-
let pid = 0;
|
|
5658
|
-
try {
|
|
5659
|
-
pid = Number.parseInt(readFileSync12(this.path(n), "utf8").trim(), 10);
|
|
5660
|
-
} catch {
|
|
5661
|
-
continue;
|
|
5662
|
-
}
|
|
5663
|
-
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
5664
|
-
try {
|
|
5665
|
-
process.kill(pid, 0);
|
|
5666
|
-
return true;
|
|
5667
|
-
} catch {
|
|
5668
|
-
}
|
|
5669
|
-
}
|
|
5670
|
-
return false;
|
|
5671
|
-
}
|
|
5672
|
-
/**
|
|
5673
|
-
* Like `acquire`, but for a resource with `max` interchangeable slots
|
|
5674
|
-
* rather than one exclusive holder — this machine's overall concurrency
|
|
5675
|
-
* capacity (ISSUE-381), which nothing needs to name a particular slot to
|
|
5676
|
-
* use. Takes whichever numbered slot is free first.
|
|
5677
|
-
*/
|
|
5678
|
-
acquireN(rawPrefix, max) {
|
|
5679
|
-
const heldBy = [];
|
|
5680
|
-
for (let i = 1; i <= max; i++) {
|
|
5681
|
-
const got = this.acquire(`${rawPrefix}-${i}`);
|
|
5682
|
-
if (got.ok) return got;
|
|
5683
|
-
heldBy.push(got.heldBy);
|
|
5684
5657
|
}
|
|
5685
|
-
return { ok: false, heldBy };
|
|
5686
5658
|
}
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
if (!
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
reason: roleMax === 1 ? `${role} is already running (pid ${roleHolders[0]})` : `${role} is already running ${roleMax} agents (its roleConcurrency cap; pid${roleHolders.length > 1 ? "s" : ""} ${[...new Set(roleHolders)].join(", ")})`
|
|
5717
|
-
};
|
|
5718
|
-
}
|
|
5719
|
-
const slot = this.acquireN("crew-slot", max);
|
|
5720
|
-
if (!slot.ok) {
|
|
5721
|
-
roleLock.release();
|
|
5722
|
-
return {
|
|
5723
|
-
ok: false,
|
|
5724
|
-
reason: `at capacity \u2014 ${max} agent(s) already running (pid${slot.heldBy.length > 1 ? "s" : ""} ${slot.heldBy.join(", ")})`
|
|
5725
|
-
};
|
|
5659
|
+
const body = {
|
|
5660
|
+
agentId: target.agentId,
|
|
5661
|
+
ticketReference: run2.ticketReference,
|
|
5662
|
+
outcome: run2.outcome,
|
|
5663
|
+
startedAt: run2.startedAt,
|
|
5664
|
+
finishedAt: run2.finishedAt,
|
|
5665
|
+
source: "client",
|
|
5666
|
+
client: "crew",
|
|
5667
|
+
model: run2.model,
|
|
5668
|
+
tokensIn: run2.tokensIn,
|
|
5669
|
+
tokensOut: run2.tokensOut,
|
|
5670
|
+
cacheReadTokens: run2.cacheReadTokens,
|
|
5671
|
+
cacheWriteTokens: run2.cacheWriteTokens,
|
|
5672
|
+
costUsd: run2.costUsd,
|
|
5673
|
+
numTurns: run2.numTurns,
|
|
5674
|
+
prefixTokens: run2.prefixTokens,
|
|
5675
|
+
promptVersion: run2.promptVersion,
|
|
5676
|
+
promptSha: run2.promptSha
|
|
5677
|
+
};
|
|
5678
|
+
const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
|
|
5679
|
+
let entry;
|
|
5680
|
+
try {
|
|
5681
|
+
entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run2.harness, provider: run2.provider });
|
|
5682
|
+
} catch (e) {
|
|
5683
|
+
if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
|
|
5684
|
+
if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
|
|
5685
|
+
if (e.status !== 400 || !run2.harness && !run2.provider && !ticketRecordId) {
|
|
5686
|
+
e.row = true;
|
|
5687
|
+
throw e;
|
|
5726
5688
|
}
|
|
5727
|
-
return { ok: true, release: () => {
|
|
5728
|
-
slot.release();
|
|
5729
|
-
roleLock.release();
|
|
5730
|
-
} };
|
|
5731
|
-
}
|
|
5732
|
-
/**
|
|
5733
|
-
* The per-ticket run lock (CREW-2821): held for the life of a run that works
|
|
5734
|
-
* the ticket (`crew-ticket-<record id>`), so a second run of the same role
|
|
5735
|
-
* on this ship walks past it instead of being handed the first run's
|
|
5736
|
-
* ticket. Same stale-pid rule as every lock here.
|
|
5737
|
-
*/
|
|
5738
|
-
acquireTicket(recordId) {
|
|
5739
|
-
return this.acquire(`crew-ticket-${recordId}`);
|
|
5740
|
-
}
|
|
5741
|
-
/** Whether a live run on this ship holds `recordId`'s ticket lock. */
|
|
5742
|
-
ticketInFlight(recordId) {
|
|
5743
|
-
const file = this.path(`.${_State.safe(`crew-ticket-${recordId}`)}.lock`);
|
|
5744
5689
|
try {
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
} catch {
|
|
5750
|
-
return false;
|
|
5690
|
+
entry = await post(target, "/agents/log", body);
|
|
5691
|
+
} catch (e2) {
|
|
5692
|
+
if (e2 instanceof AgentLogHttpError && e2.status < 500) e2.row = true;
|
|
5693
|
+
throw e2;
|
|
5751
5694
|
}
|
|
5752
5695
|
}
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5696
|
+
for (const cycle of run2.cycles) {
|
|
5697
|
+
await post(target, `/agents/log/${entry.id}/cycles`, {
|
|
5698
|
+
cycleIndex: cycle.cycleIndex,
|
|
5699
|
+
occurredAt: cycle.occurredAt,
|
|
5700
|
+
thinking: cycle.thinking,
|
|
5701
|
+
...cycle.role ? { role: cycle.role } : {}
|
|
5702
|
+
});
|
|
5703
|
+
}
|
|
5704
|
+
const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run2) : void 0;
|
|
5705
|
+
return {
|
|
5706
|
+
agentLogId: entry.id,
|
|
5707
|
+
...counters?.warning ? { counterWarning: counters.warning } : {},
|
|
5708
|
+
...counters?.retry ? { counterRetry: counters.retry } : {}
|
|
5709
|
+
};
|
|
5710
|
+
}
|
|
5711
|
+
var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
|
|
5712
|
+
async function writeCounters(target, w) {
|
|
5713
|
+
const have = await columnsOf(target, w.issuesModelId);
|
|
5714
|
+
const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
|
|
5715
|
+
if (Object.keys(body).length === 0) return;
|
|
5716
|
+
await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
|
|
5717
|
+
}
|
|
5718
|
+
async function addRunToTicket(target, t, recordId, run2) {
|
|
5719
|
+
const write = {
|
|
5720
|
+
issuesModelId: t.issuesModelId,
|
|
5721
|
+
recordId,
|
|
5722
|
+
ticketReference: run2.ticketReference,
|
|
5723
|
+
counters: t.counters,
|
|
5724
|
+
body: counterIncrements(run2, t.counters, new Set(Object.values(t.counters)))
|
|
5725
|
+
};
|
|
5726
|
+
try {
|
|
5727
|
+
await writeCounters(target, write);
|
|
5728
|
+
return {};
|
|
5729
|
+
} catch (e) {
|
|
5784
5730
|
return {
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
const n = (Number.parseInt(read(blocked) ?? "", 10) || 0) + 1;
|
|
5788
|
-
writeFileSync8(this.path(blocked), `${n}
|
|
5789
|
-
`);
|
|
5790
|
-
return n;
|
|
5791
|
-
},
|
|
5792
|
-
clearBlocked: () => {
|
|
5793
|
-
rmSync7(this.path(blocked), { force: true });
|
|
5794
|
-
},
|
|
5795
|
-
/**
|
|
5796
|
-
* The commit a deploy last failed on.
|
|
5797
|
-
*
|
|
5798
|
-
* Without this the crew retries a broken deploy on every cycle — every
|
|
5799
|
-
* two minutes, indefinitely — hammering the target and burying the
|
|
5800
|
-
* original failure under identical ones. A failed commit is retried
|
|
5801
|
-
* only when a NEW commit lands, or when an operator forces it by hand.
|
|
5802
|
-
*/
|
|
5803
|
-
deployFailedSha: () => read(failed),
|
|
5804
|
-
noteDeployFailed: (sha) => {
|
|
5805
|
-
writeFileSync8(this.path(failed), `${sha}
|
|
5806
|
-
`);
|
|
5807
|
-
},
|
|
5808
|
-
clearDeployFailed: () => {
|
|
5809
|
-
rmSync7(this.path(failed), { force: true });
|
|
5810
|
-
},
|
|
5811
|
-
/**
|
|
5812
|
-
* Consecutive test-gate failures on the same head (CREW-1368). A failure
|
|
5813
|
-
* on a new head restarts the count. Held on disk because one process is
|
|
5814
|
-
* one cycle, and "failed three cycles running" only exists across them.
|
|
5815
|
-
*/
|
|
5816
|
-
noteTestGateFailed: (sha) => {
|
|
5817
|
-
const [prev, n] = (read(gateFailed) ?? "").split(" ");
|
|
5818
|
-
const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
|
|
5819
|
-
writeFileSync8(this.path(gateFailed), `${sha} ${next}
|
|
5820
|
-
`);
|
|
5821
|
-
return next;
|
|
5822
|
-
},
|
|
5823
|
-
clearTestGateFailed: () => {
|
|
5824
|
-
rmSync7(this.path(gateFailed), { force: true });
|
|
5825
|
-
},
|
|
5826
|
-
/** Whether the board was already told about this head's red gate. */
|
|
5827
|
-
heldReported: () => {
|
|
5828
|
-
try {
|
|
5829
|
-
return readFileSync12(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
|
|
5830
|
-
} catch {
|
|
5831
|
-
return null;
|
|
5832
|
-
}
|
|
5833
|
-
},
|
|
5834
|
-
noteHeldReported: (signature) => {
|
|
5835
|
-
writeFileSync8(this.path(heldReported), `${signature}
|
|
5836
|
-
`);
|
|
5837
|
-
},
|
|
5838
|
-
clearHeldReported: () => {
|
|
5839
|
-
rmSync7(this.path(heldReported), { force: true });
|
|
5840
|
-
},
|
|
5841
|
-
testGateReported: (sha) => read(gateReported) === sha,
|
|
5842
|
-
noteTestGateReported: (sha) => {
|
|
5843
|
-
writeFileSync8(this.path(gateReported), `${sha}
|
|
5844
|
-
`);
|
|
5845
|
-
}
|
|
5731
|
+
warning: `could not add run spend to ${run2.ticketReference}: ${e.message}`,
|
|
5732
|
+
...isRetryable(e) ? { retry: write } : {}
|
|
5846
5733
|
};
|
|
5847
5734
|
}
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
const
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5735
|
+
}
|
|
5736
|
+
var queueDir = (stateDir) => join13(stateDir, "pending-agent-logs");
|
|
5737
|
+
function queueAgentRun(stateDir, name, q) {
|
|
5738
|
+
mkdirSync7(queueDir(stateDir), { recursive: true });
|
|
5739
|
+
writeFileSync8(join13(queueDir(stateDir), `${name}.json`), JSON.stringify(q));
|
|
5740
|
+
}
|
|
5741
|
+
async function replayQueuedAgentRuns(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
5742
|
+
const dir = queueDir(stateDir);
|
|
5743
|
+
if (!existsSync12(dir)) return 0;
|
|
5744
|
+
let sent = 0;
|
|
5745
|
+
for (const f of readdirSync5(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
5746
|
+
const path = join13(dir, f);
|
|
5747
|
+
let q;
|
|
5748
|
+
try {
|
|
5749
|
+
q = JSON.parse(readFileSync12(path, "utf8"));
|
|
5750
|
+
} catch {
|
|
5751
|
+
continue;
|
|
5752
|
+
}
|
|
5753
|
+
if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
|
|
5754
|
+
try {
|
|
5755
|
+
const apiKey = await keyFor(q.role);
|
|
5756
|
+
if (!apiKey) continue;
|
|
5757
|
+
const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
|
|
5758
|
+
if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
|
|
5759
|
+
if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
|
|
5760
|
+
version: 1,
|
|
5761
|
+
route: q.route,
|
|
5762
|
+
role: q.role,
|
|
5763
|
+
target: q.target,
|
|
5764
|
+
write: sentRun.counterRetry,
|
|
5765
|
+
attempts: 0,
|
|
5766
|
+
nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5767
|
+
});
|
|
5768
|
+
rmSync7(path, { force: true });
|
|
5769
|
+
sent += 1;
|
|
5770
|
+
log.emit(`sent a queued Agent Log report (${f})`);
|
|
5771
|
+
} catch (e) {
|
|
5772
|
+
if (!(e instanceof AgentLogUnreachableError)) {
|
|
5773
|
+
rmSync7(path, { force: true });
|
|
5774
|
+
log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
|
|
5775
|
+
continue;
|
|
5869
5776
|
}
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5777
|
+
const attempts = q.attempts + 1;
|
|
5778
|
+
writeFileSync8(path, JSON.stringify({
|
|
5779
|
+
...q,
|
|
5780
|
+
attempts,
|
|
5781
|
+
warned: true,
|
|
5782
|
+
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
5783
|
+
}));
|
|
5784
|
+
if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
|
|
5785
|
+
}
|
|
5786
|
+
}
|
|
5787
|
+
return sent;
|
|
5788
|
+
}
|
|
5789
|
+
var countersDir = (stateDir) => join13(stateDir, "pending-ticket-counters");
|
|
5790
|
+
function queueTicketCounters(stateDir, name, q) {
|
|
5791
|
+
mkdirSync7(countersDir(stateDir), { recursive: true });
|
|
5792
|
+
writeFileSync8(join13(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
|
|
5793
|
+
}
|
|
5794
|
+
async function replayQueuedTicketCounters(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
|
|
5795
|
+
const dir = countersDir(stateDir);
|
|
5796
|
+
if (!existsSync12(dir)) return 0;
|
|
5797
|
+
let sent = 0;
|
|
5798
|
+
for (const f of readdirSync5(dir).filter((n) => n.endsWith(".json")).sort()) {
|
|
5799
|
+
const path = join13(dir, f);
|
|
5800
|
+
let q;
|
|
5801
|
+
try {
|
|
5802
|
+
q = JSON.parse(readFileSync12(path, "utf8"));
|
|
5803
|
+
} catch {
|
|
5804
|
+
continue;
|
|
5805
|
+
}
|
|
5806
|
+
if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
|
|
5807
|
+
try {
|
|
5808
|
+
const apiKey = await keyFor(q.role);
|
|
5809
|
+
if (!apiKey) continue;
|
|
5810
|
+
await writeCounters({ ...q.target, apiKey }, q.write);
|
|
5811
|
+
rmSync7(path, { force: true });
|
|
5812
|
+
sent += 1;
|
|
5813
|
+
log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
|
|
5814
|
+
} catch (e) {
|
|
5815
|
+
if (!isRetryable(e)) {
|
|
5816
|
+
rmSync7(path, { force: true });
|
|
5817
|
+
log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
|
|
5818
|
+
continue;
|
|
5912
5819
|
}
|
|
5913
|
-
|
|
5820
|
+
const attempts = q.attempts + 1;
|
|
5821
|
+
writeFileSync8(path, JSON.stringify({
|
|
5822
|
+
...q,
|
|
5823
|
+
attempts,
|
|
5824
|
+
warned: true,
|
|
5825
|
+
nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
|
|
5826
|
+
}));
|
|
5827
|
+
if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
|
|
5828
|
+
}
|
|
5914
5829
|
}
|
|
5830
|
+
return sent;
|
|
5831
|
+
}
|
|
5832
|
+
|
|
5833
|
+
// src/ticket-attribution.ts
|
|
5834
|
+
var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
|
|
5835
|
+
function ticketMentions(text) {
|
|
5836
|
+
return [...text.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
|
|
5837
|
+
}
|
|
5838
|
+
var TicketAttributionTracker = class {
|
|
5839
|
+
#tokensByTicket = /* @__PURE__ */ new Map();
|
|
5840
|
+
#turns = 0;
|
|
5841
|
+
#current;
|
|
5842
|
+
/** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
|
|
5843
|
+
#keyByRecord = /* @__PURE__ */ new Map();
|
|
5844
|
+
#lastBy = {};
|
|
5915
5845
|
/**
|
|
5916
|
-
* The
|
|
5917
|
-
*
|
|
5918
|
-
*
|
|
5919
|
-
*
|
|
5920
|
-
* existing `fairness(route).record(...)` call — never inside `decideCycle`
|
|
5921
|
-
* itself, which does no writes. A corrupt or missing file reads as empty,
|
|
5922
|
-
* which is also deliberately what a fresh install sees: everything
|
|
5923
|
-
* currently needing a person emits once, rather than being silently
|
|
5924
|
-
* skipped as though already notified.
|
|
5846
|
+
* The ticket key most recently named in the transcript, or undefined before any.
|
|
5847
|
+
* Only the FIRST key in a turn counts: a closing summary names its own
|
|
5848
|
+
* ticket first and then lists others ("Other tickets", "Blocked by", "See
|
|
5849
|
+
* also"), and those trailing keys are not what the run worked (CREW-2598).
|
|
5925
5850
|
*/
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5851
|
+
get current() {
|
|
5852
|
+
return this.#current;
|
|
5853
|
+
}
|
|
5854
|
+
/** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
|
|
5855
|
+
note(text) {
|
|
5856
|
+
const mentions = ticketMentions(text);
|
|
5857
|
+
if (mentions.length > 0) this.#current = mentions[0];
|
|
5858
|
+
}
|
|
5859
|
+
/** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
|
|
5860
|
+
add(text, tokens) {
|
|
5861
|
+
const mentions = ticketMentions(text);
|
|
5862
|
+
if (mentions.length > 0) this.#current = mentions[0];
|
|
5863
|
+
if (this.#current === void 0 || tokens <= 0) return;
|
|
5864
|
+
this.#turns++;
|
|
5865
|
+
this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
|
|
5941
5866
|
}
|
|
5942
5867
|
/**
|
|
5943
|
-
*
|
|
5944
|
-
*
|
|
5945
|
-
*
|
|
5946
|
-
*
|
|
5947
|
-
*
|
|
5868
|
+
* Record a tool call if it is a ticket-targeted tracker write (claim,
|
|
5869
|
+
* hand-off or comment). The ticket key comes from a record uuid seen on an
|
|
5870
|
+
* earlier write when known, else from the key last named in the transcript
|
|
5871
|
+
* (a claim or hand-off announces its ticket). A comment on a record never
|
|
5872
|
+
* seen before is ignored: its uuid cannot be resolved to a key.
|
|
5948
5873
|
*/
|
|
5949
|
-
|
|
5950
|
-
const
|
|
5951
|
-
return
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
return [];
|
|
5958
|
-
}
|
|
5959
|
-
},
|
|
5960
|
-
persist: (next) => {
|
|
5961
|
-
writeFileSync8(this.path(file), JSON.stringify(next));
|
|
5962
|
-
}
|
|
5963
|
-
};
|
|
5874
|
+
noteWrite(tool, input) {
|
|
5875
|
+
const w = classifyWrite(tool, input);
|
|
5876
|
+
if (!w) return;
|
|
5877
|
+
let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
|
|
5878
|
+
if (w.kind !== "comment") key ??= this.#current;
|
|
5879
|
+
if (!key) return;
|
|
5880
|
+
if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
|
|
5881
|
+
this.#lastBy[w.kind] = key;
|
|
5964
5882
|
}
|
|
5965
5883
|
/**
|
|
5966
|
-
*
|
|
5967
|
-
*
|
|
5968
|
-
*
|
|
5884
|
+
* How the attribution is decided: a hand-off write beats a claim, a claim
|
|
5885
|
+
* beats a comment (so a follow-up ticket filed or commented on while
|
|
5886
|
+
* working never displaces the claimed one), and any write beats everything
|
|
5887
|
+
* below. Next, `hintMoved` — the post-run read-back showed the hinted
|
|
5888
|
+
* ticket changed status or assignee during the run — is a hand-off signal
|
|
5889
|
+
* for a run that wrote through MCP tools crew cannot see. Then the
|
|
5890
|
+
* mention-token heuristic, which with a hint may override it only when a
|
|
5891
|
+
* different ticket holds a strict majority of the credited tokens (a single
|
|
5892
|
+
* credited turn, like a lone end-of-run summary naming another ticket,
|
|
5893
|
+
* must not). A run with no hint
|
|
5894
|
+
* keeps the plain winner. The pre-run hint is last.
|
|
5969
5895
|
*/
|
|
5970
|
-
|
|
5971
|
-
const
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
}
|
|
5984
|
-
}
|
|
5896
|
+
decide(hint, opts = {}) {
|
|
5897
|
+
for (const by of ["handoff", "claim", "comment"]) {
|
|
5898
|
+
const ticket = this.#lastBy[by];
|
|
5899
|
+
if (ticket) return { ticket, by };
|
|
5900
|
+
}
|
|
5901
|
+
if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
|
|
5902
|
+
if (this.#tokensByTicket.size > 0) {
|
|
5903
|
+
if (!hint) return { ticket: this.winner(), by: "mentions" };
|
|
5904
|
+
const winner = this.winner(hint);
|
|
5905
|
+
if (winner === hint) return { ticket: hint, by: "mentions" };
|
|
5906
|
+
let total = 0;
|
|
5907
|
+
for (const n of this.#tokensByTicket.values()) total += n;
|
|
5908
|
+
const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
|
|
5909
|
+
return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
|
|
5910
|
+
}
|
|
5911
|
+
return { ticket: hint, by: "hint" };
|
|
5985
5912
|
}
|
|
5986
5913
|
/**
|
|
5987
|
-
*
|
|
5988
|
-
*
|
|
5989
|
-
*
|
|
5914
|
+
* The ticket key the run spent the most tokens on, or `fallback` (the
|
|
5915
|
+
* pre-run poll hint) when the transcript never named one — an
|
|
5916
|
+
* administrative run that touches the tracker without discussing a
|
|
5917
|
+
* specific ticket in its visible text still has to cite something.
|
|
5990
5918
|
*/
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
try {
|
|
5999
|
-
return statSync4(this.path(file)).mtimeMs;
|
|
6000
|
-
} catch {
|
|
6001
|
-
return null;
|
|
6002
|
-
}
|
|
5919
|
+
winner(fallback) {
|
|
5920
|
+
let best = fallback;
|
|
5921
|
+
let bestTokens = -1;
|
|
5922
|
+
for (const [ticket, tokens] of this.#tokensByTicket) {
|
|
5923
|
+
if (tokens > bestTokens) {
|
|
5924
|
+
best = ticket;
|
|
5925
|
+
bestTokens = tokens;
|
|
6003
5926
|
}
|
|
6004
|
-
}
|
|
5927
|
+
}
|
|
5928
|
+
return best;
|
|
6005
5929
|
}
|
|
6006
|
-
|
|
5930
|
+
};
|
|
5931
|
+
function isClaimWrite(tool, input) {
|
|
5932
|
+
if (!tool || !/records_controller_update$/.test(tool)) return false;
|
|
5933
|
+
const body = input?.body;
|
|
5934
|
+
return typeof body === "object" && body !== null && body.status === "in_progress";
|
|
5935
|
+
}
|
|
5936
|
+
var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
|
|
5937
|
+
function classifyWrite(tool, input) {
|
|
5938
|
+
if (!tool) return void 0;
|
|
5939
|
+
const body = input?.body;
|
|
5940
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
5941
|
+
const b = body;
|
|
5942
|
+
if (/records_controller_update$/.test(tool)) {
|
|
5943
|
+
const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
|
|
5944
|
+
if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
|
|
5945
|
+
if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
|
|
5946
|
+
}
|
|
5947
|
+
if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
|
|
5948
|
+
return void 0;
|
|
5949
|
+
}
|
|
5950
|
+
|
|
5951
|
+
// src/handoff-transcript.ts
|
|
5952
|
+
var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
|
|
5953
|
+
function resultText(content) {
|
|
5954
|
+
if (typeof content === "string") return content;
|
|
5955
|
+
if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
|
|
5956
|
+
return "";
|
|
5957
|
+
}
|
|
5958
|
+
function isTransportFailure(isError, text) {
|
|
5959
|
+
if (!isError) {
|
|
6007
5960
|
try {
|
|
6008
|
-
|
|
5961
|
+
const doc = JSON.parse(text);
|
|
5962
|
+
if (doc && typeof doc === "object") {
|
|
5963
|
+
const code = doc.statusCode;
|
|
5964
|
+
return typeof code === "number" && code >= 500;
|
|
5965
|
+
}
|
|
6009
5966
|
} catch {
|
|
6010
|
-
return 0;
|
|
6011
5967
|
}
|
|
6012
5968
|
}
|
|
5969
|
+
return TRANSPORT_FAILURE.test(text);
|
|
5970
|
+
}
|
|
5971
|
+
var HandoffTranscriptTracker = class {
|
|
5972
|
+
open = /* @__PURE__ */ new Map();
|
|
5973
|
+
/** Per ticket record: the last hand-off status write's outcome. */
|
|
5974
|
+
status = /* @__PURE__ */ new Map();
|
|
5975
|
+
/** Per ticket record: the last hand-off comment that failed after the status write's turn. */
|
|
5976
|
+
comment = /* @__PURE__ */ new Map();
|
|
5977
|
+
landed = /* @__PURE__ */ new Set();
|
|
5978
|
+
lastText = "";
|
|
5979
|
+
resultLine = "";
|
|
5980
|
+
feed(parsed) {
|
|
5981
|
+
const line = parsed;
|
|
5982
|
+
if (!line || typeof line !== "object") return;
|
|
5983
|
+
if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
|
|
5984
|
+
const content = line.message?.content;
|
|
5985
|
+
if (!Array.isArray(content)) return;
|
|
5986
|
+
for (const b of content) {
|
|
5987
|
+
if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
|
|
5988
|
+
else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
|
|
5989
|
+
const w = classifyWrite(b.name, b.input);
|
|
5990
|
+
const body = b.input?.body;
|
|
5991
|
+
if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
|
|
5992
|
+
this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
|
|
5993
|
+
}
|
|
5994
|
+
} else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
|
|
5995
|
+
const w = this.open.get(b.tool_use_id);
|
|
5996
|
+
if (!w) continue;
|
|
5997
|
+
this.open.delete(b.tool_use_id);
|
|
5998
|
+
const failed = isTransportFailure(b.is_error === true, resultText(b.content));
|
|
5999
|
+
if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
|
|
6000
|
+
else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
|
|
6001
|
+
else if (!failed) {
|
|
6002
|
+
this.comment.delete(w.recordId);
|
|
6003
|
+
this.landed.add(w.recordId);
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
6006
|
+
}
|
|
6007
|
+
}
|
|
6008
|
+
/** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
|
|
6009
|
+
pending() {
|
|
6010
|
+
const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
|
|
6011
|
+
if (!failed) return null;
|
|
6012
|
+
const [ticketRecordId, s] = failed;
|
|
6013
|
+
return {
|
|
6014
|
+
ticketRecordId,
|
|
6015
|
+
patch: s.patch,
|
|
6016
|
+
...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
|
|
6017
|
+
...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
|
|
6018
|
+
finalText: this.lastText || this.resultLine
|
|
6019
|
+
};
|
|
6020
|
+
}
|
|
6013
6021
|
};
|
|
6014
6022
|
|
|
6023
|
+
// src/pending-handoff.ts
|
|
6024
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
|
|
6025
|
+
import { join as join16 } from "path";
|
|
6026
|
+
|
|
6027
|
+
// src/ticket-branch.ts
|
|
6028
|
+
import { basename as basename6 } from "path";
|
|
6029
|
+
|
|
6030
|
+
// src/tracker.ts
|
|
6031
|
+
import { hostname } from "os";
|
|
6032
|
+
import { TablationClient } from "@tablation/client";
|
|
6033
|
+
|
|
6015
6034
|
// src/work-mode.ts
|
|
6016
6035
|
function projectWorkModes(rows, contract) {
|
|
6017
6036
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -8907,6 +8926,10 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
8907
8926
|
};
|
|
8908
8927
|
try {
|
|
8909
8928
|
const sent = await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
|
|
8929
|
+
try {
|
|
8930
|
+
clearShipAttention(new State(stateDirOfStream(id.streamPath)), agentLogRefusedKey(id.route));
|
|
8931
|
+
} catch {
|
|
8932
|
+
}
|
|
8910
8933
|
if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
|
|
8911
8934
|
if (sent.counterRetry) {
|
|
8912
8935
|
try {
|
|
@@ -8939,6 +8962,15 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
8939
8962
|
} catch {
|
|
8940
8963
|
}
|
|
8941
8964
|
}
|
|
8965
|
+
if (e instanceof AgentLogHttpError && e.row && e.status >= 400 && e.status < 500) {
|
|
8966
|
+
try {
|
|
8967
|
+
await raiseShipAttention(
|
|
8968
|
+
{ state: new State(stateDirOfStream(id.streamPath)), emit },
|
|
8969
|
+
agentLogRefusedItem(id.route, id.role, e.status)
|
|
8970
|
+
);
|
|
8971
|
+
} catch {
|
|
8972
|
+
}
|
|
8973
|
+
}
|
|
8942
8974
|
emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
|
|
8943
8975
|
}
|
|
8944
8976
|
}
|