@tablation/crew 0.81.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 +1677 -1567
- 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`);
|
|
5012
|
-
}
|
|
5013
|
-
if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
|
|
5014
|
-
problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
|
|
5015
|
-
}
|
|
5016
|
-
if (!resolved.has(c.statuses.deployed)) {
|
|
5017
|
-
problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
|
|
4654
|
+
pause(role) {
|
|
4655
|
+
writeFileSync7(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
|
|
5018
4656
|
}
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
`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`
|
|
5022
|
-
);
|
|
4657
|
+
resume(role) {
|
|
4658
|
+
rmSync6(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
|
|
5023
4659
|
}
|
|
5024
|
-
|
|
5025
|
-
|
|
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"));
|
|
5026
4667
|
}
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
);
|
|
4668
|
+
setReleasePaused(paused) {
|
|
4669
|
+
if (paused) writeFileSync7(this.path(".release-paused"), "");
|
|
4670
|
+
else rmSync6(this.path(".release-paused"), { force: true });
|
|
5031
4671
|
}
|
|
5032
|
-
|
|
5033
|
-
|
|
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;
|
|
5034
4684
|
}
|
|
5035
|
-
|
|
5036
|
-
|
|
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, "_");
|
|
5037
4702
|
}
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
4703
|
+
acquire(rawName) {
|
|
4704
|
+
const name = _State.safe(rawName);
|
|
4705
|
+
const file = this.path(`.${name}.lock`);
|
|
4706
|
+
try {
|
|
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
|
+
}
|
|
4715
|
+
} catch {
|
|
4716
|
+
}
|
|
4717
|
+
writeFileSync7(file, `${process.pid}
|
|
4718
|
+
`);
|
|
4719
|
+
return {
|
|
4720
|
+
ok: true,
|
|
4721
|
+
release: () => {
|
|
4722
|
+
try {
|
|
4723
|
+
rmSync6(file, { force: true });
|
|
4724
|
+
} catch {
|
|
4725
|
+
}
|
|
4726
|
+
}
|
|
4727
|
+
};
|
|
5041
4728
|
}
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
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;
|
|
4736
|
+
try {
|
|
4737
|
+
names = readdirSync4(this.dir);
|
|
4738
|
+
} catch {
|
|
4739
|
+
return false;
|
|
4740
|
+
}
|
|
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 {
|
|
4747
|
+
continue;
|
|
4748
|
+
}
|
|
4749
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
4750
|
+
try {
|
|
4751
|
+
process.kill(pid, 0);
|
|
4752
|
+
return true;
|
|
4753
|
+
} catch {
|
|
4754
|
+
}
|
|
4755
|
+
}
|
|
4756
|
+
return false;
|
|
5046
4757
|
}
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
};
|
|
5061
|
-
var AgentLogHttpError = class extends Error {
|
|
5062
|
-
status;
|
|
5063
|
-
constructor(message, status2) {
|
|
5064
|
-
super(message);
|
|
5065
|
-
this.status = status2;
|
|
4758
|
+
/**
|
|
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.
|
|
4763
|
+
*/
|
|
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 };
|
|
5066
4772
|
}
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
4773
|
+
/**
|
|
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.
|
|
4787
|
+
*/
|
|
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);
|
|
4798
|
+
}
|
|
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
|
+
};
|
|
4804
|
+
}
|
|
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
|
+
} };
|
|
5090
4817
|
}
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
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 };
|
|
4818
|
+
/**
|
|
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.
|
|
4823
|
+
*/
|
|
4824
|
+
acquireTicket(recordId) {
|
|
4825
|
+
return this.acquire(`crew-ticket-${recordId}`);
|
|
5120
4826
|
}
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
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) {
|
|
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`);
|
|
5141
4830
|
try {
|
|
5142
|
-
|
|
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;
|
|
5143
4835
|
} catch {
|
|
4836
|
+
return false;
|
|
5144
4837
|
}
|
|
5145
4838
|
}
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
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) {
|
|
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
|
+
};
|
|
5209
4870
|
return {
|
|
5210
|
-
|
|
5211
|
-
|
|
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
|
+
}
|
|
5212
4932
|
};
|
|
5213
4933
|
}
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
const
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
if (!apiKey) continue;
|
|
5236
|
-
const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
|
|
5237
|
-
if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
|
|
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}`);
|
|
5254
|
-
continue;
|
|
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;
|
|
5255
4955
|
}
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
}
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
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
|
+
}
|
|
5298
4998
|
}
|
|
5299
|
-
|
|
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
|
-
}
|
|
4999
|
+
};
|
|
5308
5000
|
}
|
|
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
5001
|
/**
|
|
5325
|
-
* The
|
|
5326
|
-
*
|
|
5327
|
-
*
|
|
5328
|
-
*
|
|
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.
|
|
5329
5011
|
*/
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
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
|
+
};
|
|
5337
5027
|
}
|
|
5338
|
-
/**
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
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
|
+
};
|
|
5345
5050
|
}
|
|
5346
5051
|
/**
|
|
5347
|
-
*
|
|
5348
|
-
*
|
|
5349
|
-
*
|
|
5350
|
-
* (a claim or hand-off announces its ticket). A comment on a record never
|
|
5351
|
-
* seen before is ignored: its uuid cannot be resolved to a key.
|
|
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.
|
|
5352
5055
|
*/
|
|
5353
|
-
|
|
5354
|
-
const
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
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
|
+
};
|
|
5361
5071
|
}
|
|
5362
5072
|
/**
|
|
5363
|
-
*
|
|
5364
|
-
*
|
|
5365
|
-
*
|
|
5366
|
-
* below. Next, `hintMoved` — the post-run read-back showed the hinted
|
|
5367
|
-
* ticket changed status or assignee during the run — is a hand-off signal
|
|
5368
|
-
* for a run that wrote through MCP tools crew cannot see. Then the
|
|
5369
|
-
* mention-token heuristic, which with a hint may override it only when a
|
|
5370
|
-
* different ticket holds a strict majority of the credited tokens (a single
|
|
5371
|
-
* credited turn, like a lone end-of-run summary naming another ticket,
|
|
5372
|
-
* must not). A run with no hint
|
|
5373
|
-
* keeps the plain winner. The pre-run hint is last.
|
|
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.
|
|
5374
5076
|
*/
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
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;
|
|
5389
5097
|
}
|
|
5390
|
-
return { ticket: hint, by: "hint" };
|
|
5391
5098
|
}
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
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
|
+
);
|
|
5408
5199
|
}
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
}
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
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 };
|
|
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
|
+
);
|
|
5425
5210
|
}
|
|
5426
|
-
|
|
5427
|
-
return void 0;
|
|
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 "";
|
|
5211
|
+
return lines;
|
|
5436
5212
|
}
|
|
5437
|
-
function
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
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
|
+
);
|
|
5446
5295
|
}
|
|
5447
5296
|
}
|
|
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
|
-
|
|
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
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
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`);
|
|
5544
5523
|
}
|
|
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;
|
|
5524
|
+
if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
|
|
5525
|
+
problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
|
|
5557
5526
|
}
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
return existsSync12(this.path(".crew-paused"));
|
|
5527
|
+
if (!resolved.has(c.statuses.deployed)) {
|
|
5528
|
+
problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
|
|
5561
5529
|
}
|
|
5562
|
-
|
|
5563
|
-
|
|
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
|
+
);
|
|
5564
5534
|
}
|
|
5565
|
-
|
|
5566
|
-
|
|
5535
|
+
if (!resolved.has(c.statuses.wontFix)) {
|
|
5536
|
+
problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
|
|
5567
5537
|
}
|
|
5568
|
-
|
|
5569
|
-
|
|
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
|
+
);
|
|
5570
5542
|
}
|
|
5571
|
-
|
|
5572
|
-
|
|
5543
|
+
if (resolved.has(c.statuses.approved)) {
|
|
5544
|
+
problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
|
|
5573
5545
|
}
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
* level below the whole-crew pause: `crew pause release` stops shipping
|
|
5577
|
-
* while agents keep working.
|
|
5578
|
-
*/
|
|
5579
|
-
isReleasePaused() {
|
|
5580
|
-
return existsSync12(this.path(".release-paused"));
|
|
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");
|
|
5581
5548
|
}
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
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");
|
|
5585
5552
|
}
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
* do — so a paused ship cannot merge, deploy, or file release alerts.
|
|
5591
|
-
* A release already in flight when the pause lands finishes; only new
|
|
5592
|
-
* cycles are refused.
|
|
5593
|
-
*/
|
|
5594
|
-
releaseSkipReason() {
|
|
5595
|
-
if (this.isPaused()) return "the crew is paused";
|
|
5596
|
-
if (this.isReleasePaused()) return "releases are paused";
|
|
5597
|
-
return null;
|
|
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`);
|
|
5598
5557
|
}
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
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;
|
|
5616
5579
|
}
|
|
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
|
-
};
|
|
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);
|
|
5642
5603
|
}
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
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 };
|
|
5633
|
+
}
|
|
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
|
-
};
|
|
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;
|
|
5718
5688
|
}
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
reason: `at capacity \u2014 ${max} agent(s) already running (pid${slot.heldBy.length > 1 ? "s" : ""} ${slot.heldBy.join(", ")})`
|
|
5725
|
-
};
|
|
5689
|
+
try {
|
|
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;
|
|
5726
5694
|
}
|
|
5727
|
-
return { ok: true, release: () => {
|
|
5728
|
-
slot.release();
|
|
5729
|
-
roleLock.release();
|
|
5730
|
-
} };
|
|
5731
5695
|
}
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
return this.acquire(`crew-ticket-${recordId}`);
|
|
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
|
+
});
|
|
5740
5703
|
}
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
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) {
|
|
5730
|
+
return {
|
|
5731
|
+
warning: `could not add run spend to ${run2.ticketReference}: ${e.message}`,
|
|
5732
|
+
...isRetryable(e) ? { retry: write } : {}
|
|
5733
|
+
};
|
|
5734
|
+
}
|
|
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;
|
|
5744
5748
|
try {
|
|
5745
|
-
|
|
5746
|
-
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
5747
|
-
process.kill(pid, 0);
|
|
5748
|
-
return true;
|
|
5749
|
+
q = JSON.parse(readFileSync12(path, "utf8"));
|
|
5749
5750
|
} catch {
|
|
5750
|
-
|
|
5751
|
+
continue;
|
|
5751
5752
|
}
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
const gateReported = `.test-gate-reported-${suffix}`;
|
|
5776
|
-
const heldReported = `.release-held-${suffix}`;
|
|
5777
|
-
const read = (f) => {
|
|
5778
|
-
try {
|
|
5779
|
-
return readFileSync12(this.path(f), "utf8").trim() || null;
|
|
5780
|
-
} catch {
|
|
5781
|
-
return null;
|
|
5782
|
-
}
|
|
5783
|
-
};
|
|
5784
|
-
return {
|
|
5785
|
-
blockedCount: () => Number.parseInt(read(blocked) ?? "", 10) || 0,
|
|
5786
|
-
noteBlocked: () => {
|
|
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
|
-
`);
|
|
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;
|
|
5845
5776
|
}
|
|
5846
|
-
|
|
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
|
+
}
|
|
5847
5786
|
}
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
const
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
* undefined if nothing is waiting.
|
|
5881
|
-
*/
|
|
5882
|
-
waiting: () => {
|
|
5883
|
-
const ticket = read(ticketFile);
|
|
5884
|
-
const since2 = read(sinceFile);
|
|
5885
|
-
if (!ticket || !since2) return void 0;
|
|
5886
|
-
return { ticket, since: since2, streak: Number.parseInt(read(ticketStreakFile) ?? "", 10) || 0 };
|
|
5887
|
-
},
|
|
5888
|
-
/**
|
|
5889
|
-
* Record what one cycle saw for this route: how many tickets were
|
|
5890
|
-
* actionable, which one was most urgent, and whether this route
|
|
5891
|
-
* won. Winning or running dry both reset the streak — there is nothing
|
|
5892
|
-
* left being passed over.
|
|
5893
|
-
*/
|
|
5894
|
-
record: (actionable, topTicket, won) => {
|
|
5895
|
-
if (won || actionable === 0) {
|
|
5896
|
-
clear2();
|
|
5897
|
-
return;
|
|
5898
|
-
}
|
|
5899
|
-
writeFileSync8(this.path(streakFile), `${this.readInt(streakFile) + 1}
|
|
5900
|
-
`);
|
|
5901
|
-
const prior = read(ticketFile);
|
|
5902
|
-
if (topTicket && topTicket === prior) {
|
|
5903
|
-
writeFileSync8(this.path(ticketStreakFile), `${this.readInt(ticketStreakFile) + 1}
|
|
5904
|
-
`);
|
|
5905
|
-
} else if (topTicket) {
|
|
5906
|
-
writeFileSync8(this.path(ticketFile), `${topTicket}
|
|
5907
|
-
`);
|
|
5908
|
-
writeFileSync8(this.path(sinceFile), `${(/* @__PURE__ */ new Date()).toISOString()}
|
|
5909
|
-
`);
|
|
5910
|
-
writeFileSync8(this.path(ticketStreakFile), "1\n");
|
|
5911
|
-
}
|
|
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();
|
|
@@ -6479,7 +6498,7 @@ var Tracker = class {
|
|
|
6479
6498
|
* `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
|
|
6480
6499
|
* working/idle pair, one level up (ship, not seat).
|
|
6481
6500
|
*/
|
|
6482
|
-
async beatEngaged(shipName2, connectionName, ticketRecordId) {
|
|
6501
|
+
async beatEngaged(shipName2, connectionName, ticketRecordId, since2) {
|
|
6483
6502
|
const row = await this.myShipRow(shipName2);
|
|
6484
6503
|
if (!row) return;
|
|
6485
6504
|
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
@@ -6487,7 +6506,7 @@ var Tracker = class {
|
|
|
6487
6506
|
host: hostname(),
|
|
6488
6507
|
pid: process.pid,
|
|
6489
6508
|
engaged: true,
|
|
6490
|
-
engaged_since: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6509
|
+
engaged_since: since2 ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
6491
6510
|
engaged_connection: connectionName,
|
|
6492
6511
|
engaged_ticket_id: ticketRecordId,
|
|
6493
6512
|
...this.attentionPatch(row)
|
|
@@ -8561,6 +8580,8 @@ function planAgentRun(o) {
|
|
|
8561
8580
|
eventsPath: join19(streamsDir, `${base}.events.jsonl`),
|
|
8562
8581
|
ticket: o.ticket,
|
|
8563
8582
|
ticketRecordId: o.ticketRecordId ?? null,
|
|
8583
|
+
repo: o.repo ?? null,
|
|
8584
|
+
routeName: o.route.route,
|
|
8564
8585
|
agentLog,
|
|
8565
8586
|
promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
|
|
8566
8587
|
promptSha
|
|
@@ -8905,6 +8926,10 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
8905
8926
|
};
|
|
8906
8927
|
try {
|
|
8907
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
|
+
}
|
|
8908
8933
|
if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
|
|
8909
8934
|
if (sent.counterRetry) {
|
|
8910
8935
|
try {
|
|
@@ -8937,6 +8962,15 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
|
|
|
8937
8962
|
} catch {
|
|
8938
8963
|
}
|
|
8939
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
|
+
}
|
|
8940
8974
|
emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
|
|
8941
8975
|
}
|
|
8942
8976
|
}
|
|
@@ -8975,6 +9009,8 @@ function spawnAgent(plan, emit, hintMoved) {
|
|
|
8975
9009
|
route: plan.route,
|
|
8976
9010
|
ticket: plan.ticket ?? null,
|
|
8977
9011
|
ticketRecordId: plan.ticketRecordId ?? null,
|
|
9012
|
+
repo: plan.repo ?? null,
|
|
9013
|
+
routeName: plan.routeName,
|
|
8978
9014
|
model: plan.model,
|
|
8979
9015
|
toolchain: id.toolchain,
|
|
8980
9016
|
harness: id.harness,
|
|
@@ -9546,7 +9582,7 @@ function doctorSelftestLines(stored, toolchains) {
|
|
|
9546
9582
|
}
|
|
9547
9583
|
|
|
9548
9584
|
// src/cli.ts
|
|
9549
|
-
import { resolve as resolve5, dirname as dirname20, join as
|
|
9585
|
+
import { resolve as resolve5, dirname as dirname20, join as join38, basename as basename12 } from "path";
|
|
9550
9586
|
import { fileURLToPath } from "url";
|
|
9551
9587
|
import { hostname as hostname2, homedir as homedir10 } from "os";
|
|
9552
9588
|
import { spawnSync as spawnSync7 } from "child_process";
|
|
@@ -13379,6 +13415,164 @@ Passed: ${passed}.${willClose ? " Closing this alert." : ""}`,
|
|
|
13379
13415
|
return out;
|
|
13380
13416
|
}
|
|
13381
13417
|
|
|
13418
|
+
// src/live-runs.ts
|
|
13419
|
+
import { existsSync as existsSync24, readdirSync as readdirSync10 } from "fs";
|
|
13420
|
+
import { join as join28 } from "path";
|
|
13421
|
+
|
|
13422
|
+
// src/interrupted.ts
|
|
13423
|
+
import { existsSync as existsSync23, readdirSync as readdirSync9, readFileSync as readFileSync21 } from "fs";
|
|
13424
|
+
import { join as join27 } from "path";
|
|
13425
|
+
var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
|
|
13426
|
+
function interruptedComment(b) {
|
|
13427
|
+
const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
|
|
13428
|
+
return `${INTERRUPTED_MARKER}
|
|
13429
|
+
|
|
13430
|
+
The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
|
|
13431
|
+
}
|
|
13432
|
+
var handled = /* @__PURE__ */ new Set();
|
|
13433
|
+
var pidAlive = (pid) => {
|
|
13434
|
+
try {
|
|
13435
|
+
process.kill(pid, 0);
|
|
13436
|
+
return true;
|
|
13437
|
+
} catch (e) {
|
|
13438
|
+
return e.code === "EPERM";
|
|
13439
|
+
}
|
|
13440
|
+
};
|
|
13441
|
+
function runFinished(streamPath) {
|
|
13442
|
+
const path = `${streamPath}.meta.json`;
|
|
13443
|
+
if (!existsSync23(path)) return false;
|
|
13444
|
+
try {
|
|
13445
|
+
return JSON.parse(readFileSync21(path, "utf8")).running !== true;
|
|
13446
|
+
} catch {
|
|
13447
|
+
return true;
|
|
13448
|
+
}
|
|
13449
|
+
}
|
|
13450
|
+
function orphanedStreams(o) {
|
|
13451
|
+
const dir = join27(o.stateDir, "streams");
|
|
13452
|
+
if (!existsSync23(dir)) return [];
|
|
13453
|
+
const ownerAlive = o.isOwnerAlive ?? pidAlive;
|
|
13454
|
+
const out = [];
|
|
13455
|
+
for (const name of readdirSync9(dir)) {
|
|
13456
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
13457
|
+
const streamPath = join27(dir, name);
|
|
13458
|
+
if (runFinished(streamPath)) continue;
|
|
13459
|
+
const sidecar = readRunSidecar(streamPath);
|
|
13460
|
+
if (!sidecar || sidecar.route !== o.routeLabel) continue;
|
|
13461
|
+
if (ownerAlive(sidecar.ownerPid)) continue;
|
|
13462
|
+
out.push({ streamPath, sidecar });
|
|
13463
|
+
}
|
|
13464
|
+
return out;
|
|
13465
|
+
}
|
|
13466
|
+
async function reconcileRuns(o) {
|
|
13467
|
+
let adopted = 0;
|
|
13468
|
+
let interrupted = 0;
|
|
13469
|
+
for (const { streamPath, sidecar } of orphanedStreams(o)) {
|
|
13470
|
+
if (handled.has(streamPath)) continue;
|
|
13471
|
+
handled.add(streamPath);
|
|
13472
|
+
const isAlive = o.isAlive ?? runIsAlive;
|
|
13473
|
+
const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
|
|
13474
|
+
const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
|
|
13475
|
+
const work = (async () => {
|
|
13476
|
+
try {
|
|
13477
|
+
const result = await reattachRun(streamPath, sidecar, o.emit, {
|
|
13478
|
+
agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
|
|
13479
|
+
isAlive: o.isAlive,
|
|
13480
|
+
pollMs: o.pollMs,
|
|
13481
|
+
onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
|
|
13482
|
+
});
|
|
13483
|
+
if (result.adopted) adopted++;
|
|
13484
|
+
else interrupted++;
|
|
13485
|
+
try {
|
|
13486
|
+
await o.afterRun?.(sidecar, result);
|
|
13487
|
+
} catch (e) {
|
|
13488
|
+
o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
13489
|
+
}
|
|
13490
|
+
} catch (e) {
|
|
13491
|
+
o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
13492
|
+
} finally {
|
|
13493
|
+
if (lock?.ok) lock.release();
|
|
13494
|
+
if (ticketLock?.ok) ticketLock.release();
|
|
13495
|
+
}
|
|
13496
|
+
})();
|
|
13497
|
+
if (isAlive(sidecar)) {
|
|
13498
|
+
if (o.inFlight) {
|
|
13499
|
+
o.inFlight.add(work);
|
|
13500
|
+
void work.finally(() => o.inFlight.delete(work));
|
|
13501
|
+
}
|
|
13502
|
+
} else {
|
|
13503
|
+
await work;
|
|
13504
|
+
}
|
|
13505
|
+
}
|
|
13506
|
+
return { adopted, interrupted };
|
|
13507
|
+
}
|
|
13508
|
+
|
|
13509
|
+
// src/live-runs.ts
|
|
13510
|
+
function liveRuns(stateDir, isAlive = runIsAlive) {
|
|
13511
|
+
const dir = join28(stateDir, "streams");
|
|
13512
|
+
if (!existsSync24(dir)) return [];
|
|
13513
|
+
const out = [];
|
|
13514
|
+
for (const name of readdirSync10(dir)) {
|
|
13515
|
+
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
13516
|
+
const streamPath = join28(dir, name);
|
|
13517
|
+
if (runFinished(streamPath)) continue;
|
|
13518
|
+
const sidecar = readRunSidecar(streamPath);
|
|
13519
|
+
if (!sidecar || !isAlive(sidecar)) continue;
|
|
13520
|
+
out.push({ ...sidecar, streamPath });
|
|
13521
|
+
}
|
|
13522
|
+
return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
|
|
13523
|
+
}
|
|
13524
|
+
function siblingRuns(runs, ownTicketRecordId) {
|
|
13525
|
+
return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
|
|
13526
|
+
}
|
|
13527
|
+
async function settleRunEnd(o) {
|
|
13528
|
+
const { tracker, emit } = o;
|
|
13529
|
+
const all = o.runs ?? liveRuns(o.stateDir);
|
|
13530
|
+
const slug = routeSlug(o.route.route);
|
|
13531
|
+
const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug), o.ownTicketRecordId);
|
|
13532
|
+
const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
|
|
13533
|
+
const newest = (rs) => rs[rs.length - 1];
|
|
13534
|
+
if (o.memberId) {
|
|
13535
|
+
try {
|
|
13536
|
+
if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
|
|
13537
|
+
else await tracker.setCrewStatus(o.memberId, "idle");
|
|
13538
|
+
} catch (e) {
|
|
13539
|
+
emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
|
|
13540
|
+
}
|
|
13541
|
+
}
|
|
13542
|
+
try {
|
|
13543
|
+
if (shipSiblings.length) {
|
|
13544
|
+
const n = newest(shipSiblings);
|
|
13545
|
+
await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
|
|
13546
|
+
} else {
|
|
13547
|
+
await tracker.beatIdle(o.shipName);
|
|
13548
|
+
}
|
|
13549
|
+
} catch (e) {
|
|
13550
|
+
emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
13551
|
+
}
|
|
13552
|
+
}
|
|
13553
|
+
function describeRun(run2, route, seatIds, worktreeOf = (d, n) => {
|
|
13554
|
+
try {
|
|
13555
|
+
return worktreeForNumber(d, n)?.path ?? null;
|
|
13556
|
+
} catch {
|
|
13557
|
+
return null;
|
|
13558
|
+
}
|
|
13559
|
+
}) {
|
|
13560
|
+
const ownRoute = run2.route === routeSlug(route.route);
|
|
13561
|
+
const repoDir = run2.repo ? reposOf(route).find((t) => t.name === run2.repo)?.dir : void 0;
|
|
13562
|
+
const num = run2.ticket?.replace(/^\D+/, "") ?? "";
|
|
13563
|
+
return {
|
|
13564
|
+
role: run2.role,
|
|
13565
|
+
seat: ownRoute ? seatIds[run2.role] ?? null : null,
|
|
13566
|
+
ticket: run2.ticket,
|
|
13567
|
+
ticketRecordId: run2.ticketRecordId,
|
|
13568
|
+
repo: run2.repo ?? null,
|
|
13569
|
+
worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
|
|
13570
|
+
stream: run2.streamPath,
|
|
13571
|
+
pid: run2.pid,
|
|
13572
|
+
startedAt: run2.spawnedAt
|
|
13573
|
+
};
|
|
13574
|
+
}
|
|
13575
|
+
|
|
13382
13576
|
// src/projects-cmd.ts
|
|
13383
13577
|
async function buildProjectsReport(routes, open) {
|
|
13384
13578
|
return Promise.all(routes.map(async (r) => {
|
|
@@ -13588,7 +13782,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
|
|
|
13588
13782
|
}
|
|
13589
13783
|
|
|
13590
13784
|
// src/watch.ts
|
|
13591
|
-
import { watch as watchFile, existsSync as
|
|
13785
|
+
import { watch as watchFile, existsSync as existsSync25, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
|
|
13592
13786
|
var E = "\x1B[";
|
|
13593
13787
|
var dim = (s) => `${E}2m${s}${E}0m`;
|
|
13594
13788
|
var bold = (s) => `${E}1m${s}${E}0m`;
|
|
@@ -13689,7 +13883,7 @@ function renderHeaders(states, now = Date.now()) {
|
|
|
13689
13883
|
return states.map((s) => renderHeader(s, now)).join("");
|
|
13690
13884
|
}
|
|
13691
13885
|
function readFrom(file, from) {
|
|
13692
|
-
if (!
|
|
13886
|
+
if (!existsSync25(file)) return { events: [], offset: from };
|
|
13693
13887
|
const size = statSync7(file).size;
|
|
13694
13888
|
if (size < from) return readFrom(file, 0);
|
|
13695
13889
|
if (size === from) return { events: [], offset: from };
|
|
@@ -13932,17 +14126,17 @@ function nextRoles(decision, ran) {
|
|
|
13932
14126
|
}
|
|
13933
14127
|
|
|
13934
14128
|
// src/ship-name.ts
|
|
13935
|
-
import { existsSync as
|
|
14129
|
+
import { existsSync as existsSync27, mkdirSync as mkdirSync16, readFileSync as readFileSync23, renameSync as renameSync2, writeFileSync as writeFileSync18 } from "fs";
|
|
13936
14130
|
import { dirname as dirname14 } from "path";
|
|
13937
14131
|
import { parseDocument } from "yaml";
|
|
13938
14132
|
|
|
13939
14133
|
// src/machine-id.ts
|
|
13940
14134
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
13941
14135
|
import { createHash as createHash4 } from "crypto";
|
|
13942
|
-
import { existsSync as
|
|
13943
|
-
import { dirname as dirname13, join as
|
|
14136
|
+
import { existsSync as existsSync26, mkdirSync as mkdirSync15, readFileSync as readFileSync22, writeFileSync as writeFileSync17 } from "fs";
|
|
14137
|
+
import { dirname as dirname13, join as join29 } from "path";
|
|
13944
14138
|
function machineIdPathFor(stateDir) {
|
|
13945
|
-
return
|
|
14139
|
+
return join29(stateDir, "machine-id");
|
|
13946
14140
|
}
|
|
13947
14141
|
var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
|
|
13948
14142
|
function readRawMachineId(platform2 = process.platform) {
|
|
@@ -13955,8 +14149,8 @@ function readRawMachineId(platform2 = process.platform) {
|
|
|
13955
14149
|
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
|
|
13956
14150
|
}
|
|
13957
14151
|
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
13958
|
-
if (
|
|
13959
|
-
const id =
|
|
14152
|
+
if (existsSync26(p)) {
|
|
14153
|
+
const id = readFileSync22(p, "utf8").trim();
|
|
13960
14154
|
if (id) return id;
|
|
13961
14155
|
}
|
|
13962
14156
|
}
|
|
@@ -13967,8 +14161,8 @@ function readRawMachineId(platform2 = process.platform) {
|
|
|
13967
14161
|
function ensureMachineId(stateDir, opts = {}) {
|
|
13968
14162
|
if (opts.override?.trim()) return sha256(opts.override.trim());
|
|
13969
14163
|
const path = machineIdPathFor(stateDir);
|
|
13970
|
-
if (
|
|
13971
|
-
const cached =
|
|
14164
|
+
if (existsSync26(path)) {
|
|
14165
|
+
const cached = readFileSync22(path, "utf8").trim();
|
|
13972
14166
|
if (/^[0-9a-f]{64}$/.test(cached)) return cached;
|
|
13973
14167
|
}
|
|
13974
14168
|
const raw = (opts.read ?? readRawMachineId)();
|
|
@@ -14046,19 +14240,19 @@ async function syncRouteName(i) {
|
|
|
14046
14240
|
return { status: "ok", ...base, action, name: i.local };
|
|
14047
14241
|
}
|
|
14048
14242
|
function readSyncedName(resolvedFile) {
|
|
14049
|
-
if (!
|
|
14243
|
+
if (!existsSync27(resolvedFile)) return void 0;
|
|
14050
14244
|
try {
|
|
14051
|
-
const v = JSON.parse(
|
|
14245
|
+
const v = JSON.parse(readFileSync23(resolvedFile, "utf8")).shipName;
|
|
14052
14246
|
return typeof v === "string" ? v : void 0;
|
|
14053
14247
|
} catch {
|
|
14054
14248
|
return void 0;
|
|
14055
14249
|
}
|
|
14056
14250
|
}
|
|
14057
14251
|
function writeSyncedName(resolvedFile, name) {
|
|
14058
|
-
if (!
|
|
14252
|
+
if (!existsSync27(resolvedFile)) return;
|
|
14059
14253
|
let raw;
|
|
14060
14254
|
try {
|
|
14061
|
-
raw = JSON.parse(
|
|
14255
|
+
raw = JSON.parse(readFileSync23(resolvedFile, "utf8"));
|
|
14062
14256
|
} catch {
|
|
14063
14257
|
return;
|
|
14064
14258
|
}
|
|
@@ -14073,7 +14267,7 @@ function writeSyncedName(resolvedFile, name) {
|
|
|
14073
14267
|
async function reconcileShipName(i) {
|
|
14074
14268
|
let local = i.ship.name;
|
|
14075
14269
|
try {
|
|
14076
|
-
local = shipNameOfYaml(
|
|
14270
|
+
local = shipNameOfYaml(readFileSync23(i.configFile, "utf8")) ?? local;
|
|
14077
14271
|
} catch {
|
|
14078
14272
|
}
|
|
14079
14273
|
const resolvedFile = resolvedPathFor(i.stateDir, i.route);
|
|
@@ -14097,7 +14291,7 @@ async function reconcileShipName(i) {
|
|
|
14097
14291
|
}
|
|
14098
14292
|
if (result.status !== "ok") return result;
|
|
14099
14293
|
if (result.action === "adopt" || result.action === "row-wins") {
|
|
14100
|
-
const text =
|
|
14294
|
+
const text = readFileSync23(i.configFile, "utf8");
|
|
14101
14295
|
writeFileSync18(i.configFile, setShipNameInYaml(text, result.name));
|
|
14102
14296
|
i.ship.name = result.name;
|
|
14103
14297
|
if (result.action === "row-wins") {
|
|
@@ -14743,7 +14937,7 @@ function describeCapability(i) {
|
|
|
14743
14937
|
}
|
|
14744
14938
|
|
|
14745
14939
|
// src/agents-sync-run.ts
|
|
14746
|
-
import { existsSync as
|
|
14940
|
+
import { existsSync as existsSync28, mkdirSync as mkdirSync17, readFileSync as readFileSync24, writeFileSync as writeFileSync19 } from "fs";
|
|
14747
14941
|
import { dirname as dirname15 } from "path";
|
|
14748
14942
|
async function runAgentsSync(target, o) {
|
|
14749
14943
|
const dryRun = o.dryRun ?? false;
|
|
@@ -14777,7 +14971,7 @@ async function runAgentsSync(target, o) {
|
|
|
14777
14971
|
for (const s of skillResult?.outcomes ?? []) o.say(describeSkillSyncOutcome(s));
|
|
14778
14972
|
if (!dryRun) {
|
|
14779
14973
|
const resolvedPath = resolvedPathFor(o.stateDir, target.route);
|
|
14780
|
-
const raw =
|
|
14974
|
+
const raw = existsSync28(resolvedPath) ? JSON.parse(readFileSync24(resolvedPath, "utf8")) : {};
|
|
14781
14975
|
raw.agentPersonas = personaResult.agentPersonas;
|
|
14782
14976
|
if (skillResult) raw.agentSkills = skillResult.agentSkills;
|
|
14783
14977
|
if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
|
|
@@ -14886,7 +15080,7 @@ async function showLogEntry(route, entryId, opts = {}) {
|
|
|
14886
15080
|
|
|
14887
15081
|
// src/repos-check.ts
|
|
14888
15082
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
14889
|
-
import { readFileSync as
|
|
15083
|
+
import { readFileSync as readFileSync25 } from "fs";
|
|
14890
15084
|
import { basename as basename8 } from "path";
|
|
14891
15085
|
var POSIX_SHELL = /^(?:ba|z|da|k|a|mk)?sh$|^fish$/;
|
|
14892
15086
|
function checkHookSyntax(script, shell) {
|
|
@@ -14927,7 +15121,7 @@ function checkRepoSpec(dir, opts = {}) {
|
|
|
14927
15121
|
}
|
|
14928
15122
|
let parsed;
|
|
14929
15123
|
try {
|
|
14930
|
-
parsed = parseRepoConfig(
|
|
15124
|
+
parsed = parseRepoConfig(readFileSync25(file, "utf8"), file);
|
|
14931
15125
|
} catch (e) {
|
|
14932
15126
|
if (!(e instanceof RepoConfigError)) throw e;
|
|
14933
15127
|
return { ...empty, file, error: e.message, exitCode: 1 };
|
|
@@ -14984,7 +15178,7 @@ import { isMap as isMap2, parseDocument as parseDocument3, YAMLMap } from "yaml"
|
|
|
14984
15178
|
|
|
14985
15179
|
// src/repos-cmd.ts
|
|
14986
15180
|
import { homedir as homedir9 } from "os";
|
|
14987
|
-
import { basename as basename9, join as
|
|
15181
|
+
import { basename as basename9, join as join30, resolve as resolve3 } from "path";
|
|
14988
15182
|
import { isMap, isSeq, parseDocument as parseDocument2, stringify as stringify2 } from "yaml";
|
|
14989
15183
|
var ReposError = class extends Error {
|
|
14990
15184
|
};
|
|
@@ -15262,7 +15456,7 @@ function planRepoClone(i) {
|
|
|
15262
15456
|
}
|
|
15263
15457
|
const ship = doc.get("ship", true);
|
|
15264
15458
|
const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
|
|
15265
|
-
const target = i.dir ? expandDir(i.dir, i.base) :
|
|
15459
|
+
const target = i.dir ? expandDir(i.dir, i.base) : join30(expandDir(basePath, i.base), i.route.split("/")[0], name);
|
|
15266
15460
|
const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
|
|
15267
15461
|
return { ...add, remote, target };
|
|
15268
15462
|
}
|
|
@@ -15661,7 +15855,7 @@ ${run2.err}` });
|
|
|
15661
15855
|
}
|
|
15662
15856
|
|
|
15663
15857
|
// src/sample-ticket.ts
|
|
15664
|
-
import { basename as basename10, dirname as dirname17, join as
|
|
15858
|
+
import { basename as basename10, dirname as dirname17, join as join31 } from "path";
|
|
15665
15859
|
var SAMPLE_REPO = "crew-sample";
|
|
15666
15860
|
var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
|
|
15667
15861
|
var SAMPLE_MARKER = "sample: true";
|
|
@@ -15684,8 +15878,8 @@ has something safe to work on: its test and build hooks always pass, it has no
|
|
|
15684
15878
|
remote, and \`crew sample-ticket --remove\` deletes it.
|
|
15685
15879
|
`;
|
|
15686
15880
|
function sampleDir(existing, reposBase, workspace) {
|
|
15687
|
-
if (existing.length > 0) return
|
|
15688
|
-
return
|
|
15881
|
+
if (existing.length > 0) return join31(dirname17(existing[0]), SAMPLE_REPO);
|
|
15882
|
+
return join31(reposBase, workspace, SAMPLE_REPO);
|
|
15689
15883
|
}
|
|
15690
15884
|
var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
|
|
15691
15885
|
function sampleDescription() {
|
|
@@ -15777,8 +15971,8 @@ function schedulerInstalled(plan, exists, crontab) {
|
|
|
15777
15971
|
}
|
|
15778
15972
|
|
|
15779
15973
|
// src/worktree-sweep.ts
|
|
15780
|
-
import { existsSync as
|
|
15781
|
-
import { basename as basename11, dirname as dirname18, join as
|
|
15974
|
+
import { existsSync as existsSync29, readFileSync as readFileSync26, readdirSync as readdirSync11 } from "fs";
|
|
15975
|
+
import { basename as basename11, dirname as dirname18, join as join32, resolve as resolve4 } from "path";
|
|
15782
15976
|
function planWorktreeSweep(target, tickets, contract) {
|
|
15783
15977
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
15784
15978
|
const verified = contract.statuses.verified;
|
|
@@ -15837,7 +16031,7 @@ function siblingWorktreeDirs(target) {
|
|
|
15837
16031
|
const registered = new Set(worktrees(target.dir).map((w) => resolve4(w.path)));
|
|
15838
16032
|
let names;
|
|
15839
16033
|
try {
|
|
15840
|
-
names =
|
|
16034
|
+
names = readdirSync11(parent);
|
|
15841
16035
|
} catch {
|
|
15842
16036
|
return [];
|
|
15843
16037
|
}
|
|
@@ -15845,7 +16039,7 @@ function siblingWorktreeDirs(target) {
|
|
|
15845
16039
|
for (const name of names) {
|
|
15846
16040
|
if (!name.startsWith(stem)) continue;
|
|
15847
16041
|
const m = DIR_NUMBER_SUFFIXED.exec(name);
|
|
15848
|
-
const path =
|
|
16042
|
+
const path = join32(parent, name);
|
|
15849
16043
|
if (m && !registered.has(resolve4(path))) out.push({ path, number: m[1] });
|
|
15850
16044
|
}
|
|
15851
16045
|
return out;
|
|
@@ -15880,7 +16074,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
|
|
|
15880
16074
|
let removed = 0;
|
|
15881
16075
|
let keptBranches = 0;
|
|
15882
16076
|
for (const a of actions) {
|
|
15883
|
-
if (!dryRun && base && !a.keepBranch &&
|
|
16077
|
+
if (!dryRun && base && !a.keepBranch && existsSync29(a.path) && isHandBackMerge(a.path, base)) {
|
|
15884
16078
|
if (gitOk(a.path, ["merge", "--abort"]) !== null) {
|
|
15885
16079
|
log.emit(
|
|
15886
16080
|
`abandoned the hand-back merge of ${base} in ${a.path}: ${a.ticket.issue_id} deployed without it`,
|
|
@@ -15888,7 +16082,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
|
|
|
15888
16082
|
);
|
|
15889
16083
|
}
|
|
15890
16084
|
}
|
|
15891
|
-
if (!dryRun &&
|
|
16085
|
+
if (!dryRun && existsSync29(a.path)) {
|
|
15892
16086
|
let dirty = false;
|
|
15893
16087
|
try {
|
|
15894
16088
|
dirty = status(a.path).length > 0;
|
|
@@ -15899,7 +16093,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
|
|
|
15899
16093
|
continue;
|
|
15900
16094
|
}
|
|
15901
16095
|
}
|
|
15902
|
-
if (!dryRun && a.requirePushed && a.branch &&
|
|
16096
|
+
if (!dryRun && a.requirePushed && a.branch && existsSync29(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
|
|
15903
16097
|
refuse(a, "unpushed commits", `${a.branch} has commits that are not on ${remote}`);
|
|
15904
16098
|
continue;
|
|
15905
16099
|
}
|
|
@@ -15948,7 +16142,7 @@ function isHandBackMerge(path, base) {
|
|
|
15948
16142
|
if (!msgPath) return false;
|
|
15949
16143
|
let first;
|
|
15950
16144
|
try {
|
|
15951
|
-
first =
|
|
16145
|
+
first = readFileSync26(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
|
|
15952
16146
|
} catch {
|
|
15953
16147
|
return false;
|
|
15954
16148
|
}
|
|
@@ -16053,14 +16247,14 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
|
|
|
16053
16247
|
}
|
|
16054
16248
|
|
|
16055
16249
|
// src/stream-sweep.ts
|
|
16056
|
-
import { existsSync as
|
|
16057
|
-
import { join as
|
|
16250
|
+
import { existsSync as existsSync30, readdirSync as readdirSync12, rmSync as rmSync13, statSync as statSync8 } from "fs";
|
|
16251
|
+
import { join as join33 } from "path";
|
|
16058
16252
|
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
16059
16253
|
function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
|
|
16060
|
-
const dir =
|
|
16061
|
-
if (!
|
|
16254
|
+
const dir = join33(stateDir, "streams");
|
|
16255
|
+
if (!existsSync30(dir)) return [];
|
|
16062
16256
|
const cutoff = now - retentionDays * DAY_MS2;
|
|
16063
|
-
return
|
|
16257
|
+
return readdirSync12(dir).map((name) => join33(dir, name)).filter((path) => {
|
|
16064
16258
|
try {
|
|
16065
16259
|
return statSync8(path).mtimeMs < cutoff;
|
|
16066
16260
|
} catch {
|
|
@@ -16086,9 +16280,9 @@ function applyStreamSweep(paths, dryRun, log) {
|
|
|
16086
16280
|
return removed;
|
|
16087
16281
|
}
|
|
16088
16282
|
function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
|
|
16089
|
-
if (!
|
|
16283
|
+
if (!existsSync30(stateDir)) return [];
|
|
16090
16284
|
const cutoff = now - retentionDays * DAY_MS2;
|
|
16091
|
-
return
|
|
16285
|
+
return readdirSync12(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join33(stateDir, name)).filter((path) => {
|
|
16092
16286
|
try {
|
|
16093
16287
|
return statSync8(path).mtimeMs < cutoff;
|
|
16094
16288
|
} catch {
|
|
@@ -16098,23 +16292,23 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
|
|
|
16098
16292
|
}
|
|
16099
16293
|
|
|
16100
16294
|
// src/cli.ts
|
|
16101
|
-
import { realpathSync as realpathSync3, readFileSync as readFileSync31, existsSync as
|
|
16295
|
+
import { realpathSync as realpathSync3, readFileSync as readFileSync31, existsSync as existsSync35, mkdirSync as mkdirSync22, writeFileSync as writeFileSync23, rmSync as rmSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync10, readdirSync as readdirSync16 } from "fs";
|
|
16102
16296
|
import { createInterface } from "readline/promises";
|
|
16103
16297
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
16104
16298
|
|
|
16105
16299
|
// src/ssh-keys.ts
|
|
16106
16300
|
import { execFileSync as execFileSync6 } from "child_process";
|
|
16107
|
-
import { existsSync as
|
|
16108
|
-
import { dirname as dirname19, join as
|
|
16301
|
+
import { existsSync as existsSync31, mkdirSync as mkdirSync18, readFileSync as readFileSync27, chmodSync } from "fs";
|
|
16302
|
+
import { dirname as dirname19, join as join34 } from "path";
|
|
16109
16303
|
var SshKeyError = class extends Error {
|
|
16110
16304
|
};
|
|
16111
16305
|
function shipSshKeyPathFor(stateDir) {
|
|
16112
|
-
return
|
|
16306
|
+
return join34(stateDir, "ssh", "id_ed25519");
|
|
16113
16307
|
}
|
|
16114
16308
|
function ensureShipSshKeypair(stateDir) {
|
|
16115
16309
|
const privateKeyPath = shipSshKeyPathFor(stateDir);
|
|
16116
16310
|
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
16117
|
-
if (!
|
|
16311
|
+
if (!existsSync31(privateKeyPath)) {
|
|
16118
16312
|
mkdirSync18(dirname19(privateKeyPath), { recursive: true });
|
|
16119
16313
|
try {
|
|
16120
16314
|
execFileSync6("ssh-keygen", [
|
|
@@ -16135,10 +16329,10 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
16135
16329
|
}
|
|
16136
16330
|
chmodSync(privateKeyPath, 384);
|
|
16137
16331
|
}
|
|
16138
|
-
if (!
|
|
16332
|
+
if (!existsSync31(publicKeyPath)) {
|
|
16139
16333
|
throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
|
|
16140
16334
|
}
|
|
16141
|
-
const publicKey =
|
|
16335
|
+
const publicKey = readFileSync27(publicKeyPath, "utf8").trim();
|
|
16142
16336
|
return { privateKeyPath, publicKeyPath, publicKey };
|
|
16143
16337
|
}
|
|
16144
16338
|
function sshKeygenAvailable() {
|
|
@@ -16355,9 +16549,9 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
16355
16549
|
|
|
16356
16550
|
// src/tunnel.ts
|
|
16357
16551
|
import { spawn as spawn5 } from "child_process";
|
|
16358
|
-
import { existsSync as
|
|
16552
|
+
import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, writeFileSync as writeFileSync20, unlinkSync as unlinkSync3, readdirSync as readdirSync13 } from "fs";
|
|
16359
16553
|
import { randomInt } from "crypto";
|
|
16360
|
-
import { join as
|
|
16554
|
+
import { join as join35 } from "path";
|
|
16361
16555
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
16362
16556
|
var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
|
|
16363
16557
|
var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
@@ -16424,20 +16618,20 @@ function startTunnel(opts) {
|
|
|
16424
16618
|
};
|
|
16425
16619
|
}
|
|
16426
16620
|
function tunnelStatePath(stateDir, workspaceId) {
|
|
16427
|
-
return
|
|
16621
|
+
return join35(stateDir, "passengers", `${workspaceId}.json`);
|
|
16428
16622
|
}
|
|
16429
16623
|
function readPersistedTunnel(stateDir, workspaceId) {
|
|
16430
16624
|
const path = tunnelStatePath(stateDir, workspaceId);
|
|
16431
|
-
if (!
|
|
16625
|
+
if (!existsSync32(path)) return void 0;
|
|
16432
16626
|
try {
|
|
16433
|
-
return JSON.parse(
|
|
16627
|
+
return JSON.parse(readFileSync28(path, "utf8"));
|
|
16434
16628
|
} catch {
|
|
16435
16629
|
return void 0;
|
|
16436
16630
|
}
|
|
16437
16631
|
}
|
|
16438
16632
|
function writePersistedTunnel(stateDir, state) {
|
|
16439
16633
|
const path = tunnelStatePath(stateDir, state.workspaceId);
|
|
16440
|
-
mkdirSync19(
|
|
16634
|
+
mkdirSync19(join35(stateDir, "passengers"), { recursive: true });
|
|
16441
16635
|
writeFileSync20(path, `${JSON.stringify(state, null, 2)}
|
|
16442
16636
|
`);
|
|
16443
16637
|
}
|
|
@@ -16535,9 +16729,9 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
16535
16729
|
}
|
|
16536
16730
|
}
|
|
16537
16731
|
function readAllPersistedWorkspaceIds(stateDir) {
|
|
16538
|
-
const dir =
|
|
16539
|
-
if (!
|
|
16540
|
-
return
|
|
16732
|
+
const dir = join35(stateDir, "passengers");
|
|
16733
|
+
if (!existsSync32(dir)) return [];
|
|
16734
|
+
return readdirSync13(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
|
|
16541
16735
|
}
|
|
16542
16736
|
|
|
16543
16737
|
// src/mcp-endpoint-registry.ts
|
|
@@ -16657,8 +16851,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
|
|
|
16657
16851
|
import { createServer as createHttpServer } from "http";
|
|
16658
16852
|
import { spawn as spawn6 } from "child_process";
|
|
16659
16853
|
import { randomBytes } from "crypto";
|
|
16660
|
-
import { existsSync as
|
|
16661
|
-
import { join as
|
|
16854
|
+
import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, writeFileSync as writeFileSync21, unlinkSync as unlinkSync4, readdirSync as readdirSync14 } from "fs";
|
|
16855
|
+
import { join as join36 } from "path";
|
|
16662
16856
|
|
|
16663
16857
|
// src/secret-compare.ts
|
|
16664
16858
|
import { timingSafeEqual } from "crypto";
|
|
@@ -16747,20 +16941,20 @@ var defaultSpawn2 = (cmd, args, env) => {
|
|
|
16747
16941
|
return child;
|
|
16748
16942
|
};
|
|
16749
16943
|
function syncDaemonStatePath(stateDir, workspaceId) {
|
|
16750
|
-
return
|
|
16944
|
+
return join36(stateDir, "passengers", `${workspaceId}-sync.json`);
|
|
16751
16945
|
}
|
|
16752
16946
|
function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
16753
16947
|
const path = syncDaemonStatePath(stateDir, workspaceId);
|
|
16754
|
-
if (!
|
|
16948
|
+
if (!existsSync33(path)) return void 0;
|
|
16755
16949
|
try {
|
|
16756
|
-
return JSON.parse(
|
|
16950
|
+
return JSON.parse(readFileSync29(path, "utf8"));
|
|
16757
16951
|
} catch {
|
|
16758
16952
|
return void 0;
|
|
16759
16953
|
}
|
|
16760
16954
|
}
|
|
16761
16955
|
function writePersistedSyncDaemon(stateDir, state) {
|
|
16762
16956
|
const path = syncDaemonStatePath(stateDir, state.workspaceId);
|
|
16763
|
-
mkdirSync20(
|
|
16957
|
+
mkdirSync20(join36(stateDir, "passengers"), { recursive: true });
|
|
16764
16958
|
writeFileSync21(path, `${JSON.stringify(state, null, 2)}
|
|
16765
16959
|
`);
|
|
16766
16960
|
}
|
|
@@ -16781,10 +16975,10 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
|
16781
16975
|
}
|
|
16782
16976
|
}
|
|
16783
16977
|
function readAllPersistedSyncWorkspaceIds(stateDir) {
|
|
16784
|
-
const dir =
|
|
16785
|
-
if (!
|
|
16978
|
+
const dir = join36(stateDir, "passengers");
|
|
16979
|
+
if (!existsSync33(dir)) return [];
|
|
16786
16980
|
const suffix = "-sync.json";
|
|
16787
|
-
return
|
|
16981
|
+
return readdirSync14(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
|
|
16788
16982
|
}
|
|
16789
16983
|
function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
|
|
16790
16984
|
const alive = deps.isPidAlive ?? isPidAlive;
|
|
@@ -16870,98 +17064,9 @@ function describeCause(err) {
|
|
|
16870
17064
|
}
|
|
16871
17065
|
|
|
16872
17066
|
// src/daemon.ts
|
|
16873
|
-
import { existsSync as
|
|
16874
|
-
import { join as
|
|
17067
|
+
import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, readdirSync as readdirSync15, rmSync as rmSync14, statSync as statSync9, writeFileSync as writeFileSync22 } from "fs";
|
|
17068
|
+
import { join as join37 } from "path";
|
|
16875
17069
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
16876
|
-
|
|
16877
|
-
// src/interrupted.ts
|
|
16878
|
-
import { existsSync as existsSync32, readdirSync as readdirSync13, readFileSync as readFileSync29 } from "fs";
|
|
16879
|
-
import { join as join35 } from "path";
|
|
16880
|
-
var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
|
|
16881
|
-
function interruptedComment(b) {
|
|
16882
|
-
const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
|
|
16883
|
-
return `${INTERRUPTED_MARKER}
|
|
16884
|
-
|
|
16885
|
-
The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
|
|
16886
|
-
}
|
|
16887
|
-
var handled = /* @__PURE__ */ new Set();
|
|
16888
|
-
var pidAlive = (pid) => {
|
|
16889
|
-
try {
|
|
16890
|
-
process.kill(pid, 0);
|
|
16891
|
-
return true;
|
|
16892
|
-
} catch (e) {
|
|
16893
|
-
return e.code === "EPERM";
|
|
16894
|
-
}
|
|
16895
|
-
};
|
|
16896
|
-
function runFinished(streamPath) {
|
|
16897
|
-
const path = `${streamPath}.meta.json`;
|
|
16898
|
-
if (!existsSync32(path)) return false;
|
|
16899
|
-
try {
|
|
16900
|
-
return JSON.parse(readFileSync29(path, "utf8")).running !== true;
|
|
16901
|
-
} catch {
|
|
16902
|
-
return true;
|
|
16903
|
-
}
|
|
16904
|
-
}
|
|
16905
|
-
function orphanedStreams(o) {
|
|
16906
|
-
const dir = join35(o.stateDir, "streams");
|
|
16907
|
-
if (!existsSync32(dir)) return [];
|
|
16908
|
-
const ownerAlive = o.isOwnerAlive ?? pidAlive;
|
|
16909
|
-
const out = [];
|
|
16910
|
-
for (const name of readdirSync13(dir)) {
|
|
16911
|
-
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
16912
|
-
const streamPath = join35(dir, name);
|
|
16913
|
-
if (runFinished(streamPath)) continue;
|
|
16914
|
-
const sidecar = readRunSidecar(streamPath);
|
|
16915
|
-
if (!sidecar || sidecar.route !== o.routeLabel) continue;
|
|
16916
|
-
if (ownerAlive(sidecar.ownerPid)) continue;
|
|
16917
|
-
out.push({ streamPath, sidecar });
|
|
16918
|
-
}
|
|
16919
|
-
return out;
|
|
16920
|
-
}
|
|
16921
|
-
async function reconcileRuns(o) {
|
|
16922
|
-
let adopted = 0;
|
|
16923
|
-
let interrupted = 0;
|
|
16924
|
-
for (const { streamPath, sidecar } of orphanedStreams(o)) {
|
|
16925
|
-
if (handled.has(streamPath)) continue;
|
|
16926
|
-
handled.add(streamPath);
|
|
16927
|
-
const isAlive = o.isAlive ?? runIsAlive;
|
|
16928
|
-
const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
|
|
16929
|
-
const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
|
|
16930
|
-
const work = (async () => {
|
|
16931
|
-
try {
|
|
16932
|
-
const result = await reattachRun(streamPath, sidecar, o.emit, {
|
|
16933
|
-
agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
|
|
16934
|
-
isAlive: o.isAlive,
|
|
16935
|
-
pollMs: o.pollMs,
|
|
16936
|
-
onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
|
|
16937
|
-
});
|
|
16938
|
-
if (result.adopted) adopted++;
|
|
16939
|
-
else interrupted++;
|
|
16940
|
-
try {
|
|
16941
|
-
await o.afterRun?.(sidecar, result);
|
|
16942
|
-
} catch (e) {
|
|
16943
|
-
o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
16944
|
-
}
|
|
16945
|
-
} catch (e) {
|
|
16946
|
-
o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
|
|
16947
|
-
} finally {
|
|
16948
|
-
if (lock?.ok) lock.release();
|
|
16949
|
-
if (ticketLock?.ok) ticketLock.release();
|
|
16950
|
-
}
|
|
16951
|
-
})();
|
|
16952
|
-
if (isAlive(sidecar)) {
|
|
16953
|
-
if (o.inFlight) {
|
|
16954
|
-
o.inFlight.add(work);
|
|
16955
|
-
void work.finally(() => o.inFlight.delete(work));
|
|
16956
|
-
}
|
|
16957
|
-
} else {
|
|
16958
|
-
await work;
|
|
16959
|
-
}
|
|
16960
|
-
}
|
|
16961
|
-
return { adopted, interrupted };
|
|
16962
|
-
}
|
|
16963
|
-
|
|
16964
|
-
// src/daemon.ts
|
|
16965
17070
|
var DEFAULT_FLOOR_MS = 5e3;
|
|
16966
17071
|
var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
|
|
16967
17072
|
var defaultSleep = (ms2) => new Promise((resolve6) => setTimeout(resolve6, ms2));
|
|
@@ -17030,8 +17135,8 @@ function latestMtime(path) {
|
|
|
17030
17135
|
if (stat.isFile()) return stat.mtimeMs;
|
|
17031
17136
|
if (!stat.isDirectory()) return 0;
|
|
17032
17137
|
let latest = stat.mtimeMs;
|
|
17033
|
-
for (const entry of
|
|
17034
|
-
latest = Math.max(latest, latestMtime(
|
|
17138
|
+
for (const entry of readdirSync15(path, { withFileTypes: true })) {
|
|
17139
|
+
latest = Math.max(latest, latestMtime(join37(path, entry.name)));
|
|
17035
17140
|
}
|
|
17036
17141
|
return latest;
|
|
17037
17142
|
}
|
|
@@ -17040,13 +17145,13 @@ function computeTreeSignature(paths) {
|
|
|
17040
17145
|
}
|
|
17041
17146
|
function makeStaleChecker(paths, opts = {}) {
|
|
17042
17147
|
const initial = computeTreeSignature(paths);
|
|
17043
|
-
return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 &&
|
|
17148
|
+
return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync34(opts.reloadSentinel);
|
|
17044
17149
|
}
|
|
17045
17150
|
function reloadSentinelFor(stateDir) {
|
|
17046
|
-
return
|
|
17151
|
+
return join37(stateDir, "reload-requested");
|
|
17047
17152
|
}
|
|
17048
17153
|
function daemonStampFor(stateDir) {
|
|
17049
|
-
return
|
|
17154
|
+
return join37(stateDir, "daemon.started");
|
|
17050
17155
|
}
|
|
17051
17156
|
function noteDaemonStarted(stateDir, now = Date.now()) {
|
|
17052
17157
|
mkdirSync21(stateDir, { recursive: true });
|
|
@@ -17309,13 +17414,15 @@ async function reconcileForRoute(ctx, o) {
|
|
|
17309
17414
|
await guardRunHandoff(route, ship, tracker, sc.role, sc.ticketRecordId, emit, sc.spawnedAt);
|
|
17310
17415
|
}
|
|
17311
17416
|
} finally {
|
|
17312
|
-
|
|
17313
|
-
|
|
17314
|
-
|
|
17315
|
-
|
|
17316
|
-
|
|
17317
|
-
|
|
17318
|
-
|
|
17417
|
+
await settleRunEnd({
|
|
17418
|
+
tracker,
|
|
17419
|
+
shipName: ship.name,
|
|
17420
|
+
route,
|
|
17421
|
+
role: sc.role,
|
|
17422
|
+
memberId,
|
|
17423
|
+
ownTicketRecordId: sc.ticketRecordId,
|
|
17424
|
+
stateDir: ship.stateDir,
|
|
17425
|
+
emit
|
|
17319
17426
|
});
|
|
17320
17427
|
}
|
|
17321
17428
|
}
|
|
@@ -17469,18 +17576,16 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
|
|
|
17469
17576
|
await spawnAgent(plan, emit, hintMoved);
|
|
17470
17577
|
await guardRunHandoff(route, ship, tracker, role, workingId, emit, runStart);
|
|
17471
17578
|
} finally {
|
|
17472
|
-
|
|
17473
|
-
|
|
17474
|
-
|
|
17475
|
-
|
|
17476
|
-
|
|
17477
|
-
|
|
17478
|
-
|
|
17479
|
-
|
|
17480
|
-
|
|
17481
|
-
}
|
|
17482
|
-
emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
17483
|
-
}
|
|
17579
|
+
await settleRunEnd({
|
|
17580
|
+
tracker,
|
|
17581
|
+
shipName: ship.name,
|
|
17582
|
+
route,
|
|
17583
|
+
role,
|
|
17584
|
+
memberId,
|
|
17585
|
+
ownTicketRecordId: workingId,
|
|
17586
|
+
stateDir: ship.stateDir,
|
|
17587
|
+
emit
|
|
17588
|
+
});
|
|
17484
17589
|
}
|
|
17485
17590
|
}
|
|
17486
17591
|
async function buildEnvironment(route, ship, ticket) {
|
|
@@ -17922,7 +18027,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
17922
18027
|
} else {
|
|
17923
18028
|
const toleratesNoRoutes = command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
|
|
17924
18029
|
const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
|
|
17925
|
-
if (configless && !
|
|
18030
|
+
if (configless && !existsSync35(findConfigFile(CREW_HOME))) {
|
|
17926
18031
|
cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
|
|
17927
18032
|
} else {
|
|
17928
18033
|
cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
|
|
@@ -18160,8 +18265,8 @@ if (command === "passenger-sync-daemon") {
|
|
|
18160
18265
|
}
|
|
18161
18266
|
if (host === "linux" && hasSystemd) {
|
|
18162
18267
|
const label = labelFor(CREW_HOME, "run");
|
|
18163
|
-
const staleTimer =
|
|
18164
|
-
if (
|
|
18268
|
+
const staleTimer = join38(homedir10(), ".config", "systemd", "user", `${label}.timer`);
|
|
18269
|
+
if (existsSync35(staleTimer)) {
|
|
18165
18270
|
process.stdout.write(
|
|
18166
18271
|
`removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
|
|
18167
18272
|
`
|
|
@@ -18188,7 +18293,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
18188
18293
|
const tracker = new Tracker(c, cfg.ship);
|
|
18189
18294
|
problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
|
|
18190
18295
|
for (const r of await resolvedRepos(c)) {
|
|
18191
|
-
if (!
|
|
18296
|
+
if (!existsSync35(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
|
|
18192
18297
|
if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
|
|
18193
18298
|
readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
|
|
18194
18299
|
}
|
|
@@ -18206,7 +18311,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
18206
18311
|
}
|
|
18207
18312
|
async function doctorFix(o) {
|
|
18208
18313
|
const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
|
|
18209
|
-
const unitExists = (p) =>
|
|
18314
|
+
const unitExists = (p) => existsSync35(p);
|
|
18210
18315
|
const crontab = o.host === "linux" ? spawnSync7("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
|
|
18211
18316
|
const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
|
|
18212
18317
|
const plan = planFix({ routes, schedulerAbsent, host: o.host });
|
|
@@ -18995,18 +19100,16 @@ ${cu.tail.trimEnd()}
|
|
|
18995
19100
|
await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit, runStart);
|
|
18996
19101
|
} finally {
|
|
18997
19102
|
dropLock();
|
|
18998
|
-
|
|
18999
|
-
|
|
19000
|
-
|
|
19001
|
-
|
|
19002
|
-
|
|
19003
|
-
|
|
19004
|
-
|
|
19005
|
-
|
|
19006
|
-
|
|
19007
|
-
}
|
|
19008
|
-
wemit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
19009
|
-
}
|
|
19103
|
+
await settleRunEnd({
|
|
19104
|
+
tracker: fleetTracker,
|
|
19105
|
+
shipName: cfg.ship.name,
|
|
19106
|
+
route: w.route,
|
|
19107
|
+
role: w.role,
|
|
19108
|
+
memberId: fleetMemberId,
|
|
19109
|
+
ownTicketRecordId: fleetWorkingId,
|
|
19110
|
+
stateDir: cfg.ship.stateDir,
|
|
19111
|
+
emit: wemit
|
|
19112
|
+
});
|
|
19010
19113
|
}
|
|
19011
19114
|
if (!skipInlineRelease) await releaseFleet();
|
|
19012
19115
|
break;
|
|
@@ -19236,18 +19339,16 @@ ${cu.tail.trimEnd()}
|
|
|
19236
19339
|
await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit, runStart);
|
|
19237
19340
|
} finally {
|
|
19238
19341
|
dropLock();
|
|
19239
|
-
|
|
19240
|
-
|
|
19241
|
-
|
|
19242
|
-
|
|
19243
|
-
|
|
19244
|
-
|
|
19245
|
-
|
|
19246
|
-
|
|
19247
|
-
|
|
19248
|
-
}
|
|
19249
|
-
emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
|
|
19250
|
-
}
|
|
19342
|
+
await settleRunEnd({
|
|
19343
|
+
tracker: tracker2,
|
|
19344
|
+
shipName: cfg.ship.name,
|
|
19345
|
+
route,
|
|
19346
|
+
role: current,
|
|
19347
|
+
memberId,
|
|
19348
|
+
ownTicketRecordId: workingId,
|
|
19349
|
+
stateDir: cfg.ship.stateDir,
|
|
19350
|
+
emit
|
|
19351
|
+
});
|
|
19251
19352
|
}
|
|
19252
19353
|
ran.push(current);
|
|
19253
19354
|
if (ran.length >= maxRoles || explicitRole) break;
|
|
@@ -19331,7 +19432,7 @@ ${cu.tail.trimEnd()}
|
|
|
19331
19432
|
process.exit(1);
|
|
19332
19433
|
}
|
|
19333
19434
|
requireArmed("run the daemon loop");
|
|
19334
|
-
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [
|
|
19435
|
+
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join38(CREW_HOME, "src"), join38(CREW_HOME, "dist"), join38(CREW_HOME, "bin")];
|
|
19335
19436
|
const configPaths = daemonConfigPaths(cfg);
|
|
19336
19437
|
watchPaths.push(...configPaths);
|
|
19337
19438
|
noteDaemonStarted(cfg.ship.stateDir);
|
|
@@ -19560,7 +19661,7 @@ ${cu.tail.trimEnd()}
|
|
|
19560
19661
|
`);
|
|
19561
19662
|
if (!dryRun) {
|
|
19562
19663
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
19563
|
-
const raw =
|
|
19664
|
+
const raw = existsSync35(resolvedPath) ? JSON.parse(readFileSync31(resolvedPath, "utf8")) : {};
|
|
19564
19665
|
raw.agentSkills = result.agentSkills;
|
|
19565
19666
|
mkdirSync22(dirOf(resolvedPath), { recursive: true });
|
|
19566
19667
|
writeFileSync23(resolvedPath, `${JSON.stringify(raw, null, 2)}
|
|
@@ -19664,10 +19765,10 @@ ${cu.tail.trimEnd()}
|
|
|
19664
19765
|
try {
|
|
19665
19766
|
const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
|
|
19666
19767
|
const dir = repoDirOf(configText, dirname20(cfg.configFile), a.route, a.name);
|
|
19667
|
-
if (!
|
|
19668
|
-
const files = { exists: (r) =>
|
|
19768
|
+
if (!existsSync35(dir)) throw new ReposError(`${dir} does not exist on disk`);
|
|
19769
|
+
const files = { exists: (r) => existsSync35(resolvePath(dir, r)), list: (r) => {
|
|
19669
19770
|
try {
|
|
19670
|
-
return
|
|
19771
|
+
return readdirSync16(resolvePath(dir, r));
|
|
19671
19772
|
} catch {
|
|
19672
19773
|
return [];
|
|
19673
19774
|
}
|
|
@@ -19783,7 +19884,7 @@ ${cu.tail.trimEnd()}
|
|
|
19783
19884
|
if (sub === "list" && !routeName) usage2();
|
|
19784
19885
|
if (sub === "add") {
|
|
19785
19886
|
try {
|
|
19786
|
-
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) =>
|
|
19887
|
+
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync35(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
|
|
19787
19888
|
routeName = a.route;
|
|
19788
19889
|
addPath = a.path;
|
|
19789
19890
|
addName = a.name;
|
|
@@ -19812,7 +19913,7 @@ ${cu.tail.trimEnd()}
|
|
|
19812
19913
|
}
|
|
19813
19914
|
const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
|
|
19814
19915
|
let resolvedRemotes;
|
|
19815
|
-
if (
|
|
19916
|
+
if (existsSync35(resolvedFile)) {
|
|
19816
19917
|
try {
|
|
19817
19918
|
resolvedRemotes = JSON.parse(readFileSync31(resolvedFile, "utf8"));
|
|
19818
19919
|
} catch {
|
|
@@ -19821,7 +19922,7 @@ ${cu.tail.trimEnd()}
|
|
|
19821
19922
|
const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
|
|
19822
19923
|
const text = configText;
|
|
19823
19924
|
const base = dirname20(cfg.configFile);
|
|
19824
|
-
const isCheckout = (d) =>
|
|
19925
|
+
const isCheckout = (d) => existsSync35(`${d}/.git`);
|
|
19825
19926
|
try {
|
|
19826
19927
|
if (sub === "list") {
|
|
19827
19928
|
if (flag("json")) {
|
|
@@ -19831,7 +19932,7 @@ ${cu.tail.trimEnd()}
|
|
|
19831
19932
|
base,
|
|
19832
19933
|
resolved: resolvedRemotes,
|
|
19833
19934
|
originOf,
|
|
19834
|
-
exists: (d) =>
|
|
19935
|
+
exists: (d) => existsSync35(d),
|
|
19835
19936
|
isCheckout,
|
|
19836
19937
|
repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
|
|
19837
19938
|
try {
|
|
@@ -19852,7 +19953,7 @@ ${cu.tail.trimEnd()}
|
|
|
19852
19953
|
for (const r of repos) {
|
|
19853
19954
|
const origin2 = originOf(r.dir);
|
|
19854
19955
|
const row = matchRepoName(origin2, resolvedRemotes);
|
|
19855
|
-
const note = !
|
|
19956
|
+
const note = !existsSync35(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
|
|
19856
19957
|
process.stdout.write(`${r.name} ${r.dir} ${note}
|
|
19857
19958
|
`);
|
|
19858
19959
|
}
|
|
@@ -19864,9 +19965,9 @@ ${cu.tail.trimEnd()}
|
|
|
19864
19965
|
` : `${human}
|
|
19865
19966
|
`);
|
|
19866
19967
|
const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
|
|
19867
|
-
if (
|
|
19968
|
+
if (existsSync35(plan2.target) && (existsSync35(`${plan2.target}/.git`) || readdirSync16(plan2.target).length > 0)) {
|
|
19868
19969
|
throw new ReposError(
|
|
19869
|
-
|
|
19970
|
+
existsSync35(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
|
|
19870
19971
|
);
|
|
19871
19972
|
}
|
|
19872
19973
|
if (dryRun) {
|
|
@@ -19891,7 +19992,7 @@ ${cu.tail.trimEnd()}
|
|
|
19891
19992
|
break;
|
|
19892
19993
|
}
|
|
19893
19994
|
const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
|
|
19894
|
-
const top =
|
|
19995
|
+
const top = existsSync35(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
|
|
19895
19996
|
if (!top) throw new ReposError(`${target} is not a git checkout`);
|
|
19896
19997
|
const origin = originOf(top);
|
|
19897
19998
|
const plan = planRepoAdd({
|
|
@@ -19943,7 +20044,7 @@ ${cu.tail.trimEnd()}
|
|
|
19943
20044
|
const created = await rowTracker.createRepoRow(rowPayload);
|
|
19944
20045
|
process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
|
|
19945
20046
|
`);
|
|
19946
|
-
if (
|
|
20047
|
+
if (existsSync35(resolvedFile)) {
|
|
19947
20048
|
const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
|
|
19948
20049
|
raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
|
|
19949
20050
|
raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
|
|
@@ -20360,7 +20461,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
20360
20461
|
}
|
|
20361
20462
|
}
|
|
20362
20463
|
let previous;
|
|
20363
|
-
const hadPreviousFile =
|
|
20464
|
+
const hadPreviousFile = existsSync35(resolvedPath);
|
|
20364
20465
|
if (hadPreviousFile) {
|
|
20365
20466
|
try {
|
|
20366
20467
|
previous = JSON.parse(readFileSync31(resolvedPath, "utf8"));
|
|
@@ -21402,7 +21503,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21402
21503
|
}
|
|
21403
21504
|
if (row) {
|
|
21404
21505
|
await tracker.deleteRepoRow(row.id);
|
|
21405
|
-
if (
|
|
21506
|
+
if (existsSync35(resolvedFile)) {
|
|
21406
21507
|
const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
|
|
21407
21508
|
if (raw.repoNames) delete raw.repoNames[row.id];
|
|
21408
21509
|
if (raw.repoRemotes) delete raw.repoRemotes[row.id];
|
|
@@ -21410,21 +21511,21 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21410
21511
|
`);
|
|
21411
21512
|
}
|
|
21412
21513
|
}
|
|
21413
|
-
if (
|
|
21514
|
+
if (existsSync35(join38(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync15(dir, { recursive: true, force: true });
|
|
21414
21515
|
say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
|
|
21415
21516
|
break;
|
|
21416
21517
|
}
|
|
21417
21518
|
const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
|
|
21418
|
-
const fresh = !
|
|
21419
|
-
if (
|
|
21519
|
+
const fresh = !existsSync35(join38(dir, ".git"));
|
|
21520
|
+
if (existsSync35(dir) && fresh && readdirSync16(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
|
|
21420
21521
|
if (dryRun) {
|
|
21421
21522
|
say("dry-run", { dir, newCheckout: fresh, repoRow: row?.id ?? null }, `would ${fresh ? "create" : "reuse"} ${dir}${row ? "" : ", create the Repos row"}, and file one ${tracker.contract.statuses.approved} ticket`);
|
|
21422
21523
|
break;
|
|
21423
21524
|
}
|
|
21424
21525
|
if (fresh) {
|
|
21425
21526
|
mkdirSync22(dir, { recursive: true });
|
|
21426
|
-
writeFileSync23(
|
|
21427
|
-
writeFileSync23(
|
|
21527
|
+
writeFileSync23(join38(dir, "README.md"), SAMPLE_README);
|
|
21528
|
+
writeFileSync23(join38(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
|
|
21428
21529
|
const g = (...a) => {
|
|
21429
21530
|
const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
|
|
21430
21531
|
if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
|
|
@@ -21442,7 +21543,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21442
21543
|
spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
|
|
21443
21544
|
});
|
|
21444
21545
|
repoId = (await tracker.createRepoRow(payload)).id;
|
|
21445
|
-
if (
|
|
21546
|
+
if (existsSync35(resolvedFile)) {
|
|
21446
21547
|
const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
|
|
21447
21548
|
raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
|
|
21448
21549
|
raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
|
|
@@ -21573,6 +21674,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21573
21674
|
const todo = await statusTracker.openTickets().then((open) => operatorTodo(open, statusTracker.contract, route.resolved?.operator ?? "")).catch(() => []);
|
|
21574
21675
|
const projects = projectModeLabels(await statusTracker.projectRowsWithModes().catch(() => []));
|
|
21575
21676
|
const crewMinVersion = await readCrewFloor(route, cfg.ship) ?? null;
|
|
21677
|
+
const runs = liveRuns(cfg.ship.stateDir);
|
|
21576
21678
|
if (flag("json")) {
|
|
21577
21679
|
const nameSync = await reconcileRouteShipName({ route, ship: cfg.ship, configFile: cfg.configFile, readOnly: true });
|
|
21578
21680
|
const repos = await resolvedRepos(route);
|
|
@@ -21590,7 +21692,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21590
21692
|
label: ROLE_LABEL[r],
|
|
21591
21693
|
crewId: seatId ?? null,
|
|
21592
21694
|
name: crewRow?.name ?? null,
|
|
21593
|
-
personaName
|
|
21695
|
+
personaName,
|
|
21696
|
+
// Additive (CREW-2822): how many runs this seat has live right now.
|
|
21697
|
+
running: runs.filter((x) => x.role === r && x.route === routeSlug(route.route)).length
|
|
21594
21698
|
};
|
|
21595
21699
|
});
|
|
21596
21700
|
process.stdout.write(`${JSON.stringify({
|
|
@@ -21633,6 +21737,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21633
21737
|
roles: ROLE_NAMES.map((r) => ({ name: r, label: ROLE_LABEL[r], paused: state.isRolePaused(r) })),
|
|
21634
21738
|
seats: seats2
|
|
21635
21739
|
},
|
|
21740
|
+
// Additive (CREW-2822): every live run on this ship. `version` stays 1.
|
|
21741
|
+
runs: runs.map((x) => describeRun(x, route, seatIds)),
|
|
21636
21742
|
watermark: state.watermark(),
|
|
21637
21743
|
route: {
|
|
21638
21744
|
name: route.route,
|
|
@@ -21745,6 +21851,10 @@ watermark: ${state.watermark()}
|
|
|
21745
21851
|
`
|
|
21746
21852
|
);
|
|
21747
21853
|
}
|
|
21854
|
+
for (const x of runs) {
|
|
21855
|
+
process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt})
|
|
21856
|
+
`);
|
|
21857
|
+
}
|
|
21748
21858
|
if (shipRows.length) {
|
|
21749
21859
|
process.stdout.write("ships:\n");
|
|
21750
21860
|
for (const s of shipRows) {
|
|
@@ -21773,7 +21883,7 @@ watermark: ${state.watermark()}
|
|
|
21773
21883
|
}
|
|
21774
21884
|
for (const r of repos) {
|
|
21775
21885
|
const remote = r.config.branch.remote;
|
|
21776
|
-
if (
|
|
21886
|
+
if (existsSync35(r.dir)) {
|
|
21777
21887
|
pushAuth.push({
|
|
21778
21888
|
route: c.route,
|
|
21779
21889
|
repo: r.name,
|
|
@@ -21834,7 +21944,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
21834
21944
|
`);
|
|
21835
21945
|
try {
|
|
21836
21946
|
const evFile = eventFileFor(cfg.ship.stateDir);
|
|
21837
|
-
const evSize =
|
|
21947
|
+
const evSize = existsSync35(evFile) ? statSync10(evFile).size : 0;
|
|
21838
21948
|
process.stdout.write(
|
|
21839
21949
|
evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
|
|
21840
21950
|
` : "events log: ok\n"
|