CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

tessl-labs/factory

Skills that Tessl factory loops run: implement a Linear or GitHub issue as a pull request, shepherd that pull request through review and checks, wake the shepherd for pull requests that a push to their base branch left in conflict, and keep the work in step when a person stops, ends, answers or closes it.

Quality

88%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

SKILL.mdskills/merge-conflict-sweep/

name:
merge-conflict-sweep
description:
When a branch moves, start one round of the factory's fixer action for each Tessl factory pull request against it that now has a merge conflict and whose Linear or GitHub issue the factory is still working, so the fixer starts on the conflict before the pull request's owner looks at it again.
schemas:
{"inputs":{"$ref":"./schemas/inputs.schema.json"},"output":{"$ref":"./schemas/output.schema.json"}}

Merge conflict sweep

You run one sweep. A listing script gathers the facts about every conflicting factory pull request, you decide from those facts which issue each one belongs to and whether to wake the fixer for it, and a trigger script makes the trigger calls you chose and writes the run's result. The fixer is the action the listing names in fixerAction: the FIXER_ACTION input, shepherd when unset.

What done looks like

The run is done when the trigger script has written launch-result.json at the clone root, with a decision for every fact sheet the listing wrote. Take the first that applies:

  1. TESSL_LAUNCH_DEADLINE is not set. Write a failed result saying the run has no deadline, and stop.
  2. The trigger script wrote launch-result.json. Leave it as it is, whatever the script's exit code, and stop.
  3. A script is still running. Wait for it, as below.
  4. The listing has not run. Run it, with the listing end time.
  5. The listing ended without writing listing.json. Write a failed result whose reason is the last line it printed, and stop.
  6. A fact sheet has no decision. Decide it, as below.
  7. The trigger script has not run, or exited 2 naming decisions that are missing or malformed. Fix the decisions it names, then run it, with the trigger end time. It makes no trigger call before every decision is well formed. A decision with no fact sheet is deleted, not fixed. After the third exit 2, write a failed result whose reason is its last line, and stop.
  8. The trigger script ended without writing a result for any other reason. Write a failed result whose reason says so, followed by the last line it printed, and stop.

If the run fits none of these, write a failed result saying what you found.

From the clone root, where <skill-dir> is this skill's directory:

node <skill-dir>/scripts/list-conflicting-prs.mjs --until <listing end time>
node <skill-dir>/scripts/trigger-shepherd-rounds.mjs --until <trigger end time>

Both read and write the sweep's directory, $TMPDIR/merge-conflict-sweep (/tmp/merge-conflict-sweep when TMPDIR is unset). The listing's summary line prints the absolute paths of its facts and decisions directories.

Invariants

What the sweep touches

  1. The sweep reads and triggers, nothing else. Never comment on, label, push to or edit a pull request, and never write to Linear or a GitHub issue. The only writes are the trigger script's tessl action trigger calls for the fixer.
  2. You fetch nothing yourself: no gh, no Linear, no re-read of a pull request or issue, and no trigger call by hand. The fact sheets are the whole record you decide from; the scripts read everything else.
  3. You never edit launch-result.json, the listing's files or the fact sheets. You write and delete only decision files, and write a failed result where the situations above say to.

Time

  1. Each script stops by the --until time you give it, and reports what it did not reach as skipped, with that reason. The run can be stopped at any moment after TESSL_LAUNCH_DEADLINE, and is never stopped before it.

Deciding

  1. Every fact sheet gets exactly one decision, and every decision carries its evidence: the issue, where its identifier came from, quoted, and a one-line reason.
  2. Wake the fixer only for a pull request whose issue you identified by the rule below and that is eligible. Waking it for an issue that is not costs a full round on an expensive model; leaving an eligible one costs a conflict that waits for the next sweep.

Your world

The deadline. TESSL_LAUNCH_DEADLINE holds the time, in ISO 8601, after which the platform can stop the run. The end times come from it:

  • the listing's end time is the earlier of 45 minutes from now and 45 minutes before the deadline, which leaves the triggers their 30 minutes of retries, your decisions and the wrap-up;
  • the trigger script's end time is 5 minutes before the deadline.

These print them, the listing's just before you start it and the trigger script's at any time:

node -e 'const d=Date.parse(process.env.TESSL_LAUNCH_DEADLINE),m=60000;console.log(new Date(Math.min(Date.now()+45*m,d-45*m)).toISOString())'
node -e 'console.log(new Date(Date.parse(process.env.TESSL_LAUNCH_DEADLINE)-5*60000).toISOString())'

A listing end time already past leaves the listing nothing to do: it exits 2, and that is situation 5.

The listing reads its inputs from .cloud-launch/inputs.json, and the pushed branch and repository id from the github:push event in .cloud-launch/trigger-events.json; a run started by hand sweeps the default branch. It lists the open pull requests against the branch, keeps the factory's (author, branch prefix, labels, no fork), and lists again every 30 seconds while any of them has no known mergeability, until shortly before its end time. It then reads each issue a conflicting one names: from Linear, every identifier-shaped token in its branch, title and description (a branch whose suffix is issue-<number> gives none), and from GitHub, in the repository you are cloned from, the number in a branch suffix such as issue-12 and every #<number> on a description line that begins with Closes. It reads Linear from $LINEAR_API_URL/graphql with the run's LINEAR_TOKEN, and a run without either of them reads nothing from Linear. It prints one summary line and writes:

  • listing.json: the counts, the base branch, whether this is a dry run, the fixer action, and the numbers of the pull requests that got a fact sheet;
  • listing-skipped.json: every other open pull request, with the reason;
  • facts/<number>.json: one fact sheet per conflicting factory pull request.

A fact sheet holds the pull request's number, url, headSha, branch, its branchSuffix after the factory prefix, its title, and descriptionLines, the description's lines that say Closes or carry an identifier-shaped token (long ones cut, and the middle dropped past 40 lines, counted in descriptionLinesOmitted; a line that says Closes is always kept whole). issues maps what was read to its facts:

  • each Linear token, upper-cased such as ABC-45, to tracker: "linear", state (its name), stateType (such as started, completed or canceled), delegateId (the id of its delegate, null for none), stateEnteredAt (when the issue entered its current state) and hoursInState as of collectedAt;
  • each GitHub issue, as #12, to tracker: "github", state (OPEN or CLOSED) and its labels;
  • or to unknown: true when there is no such issue (a GitHub number that is a pull request is not an issue), or unreadable with the failed read.

The rest come from the factory's settings: tesslAppLinearUserId, the Tessl App's Linear user id, null when the factory gave none; linearActiveStates, the Linear states a woken issue must be in, null for any state; ageLimitHours, the age limit in hours for each Linear state it names, none for a state it does not name; githubIssueLabel, the label a GitHub issue must carry; and issueLabelFromDescription, the label the description's <!-- tessl-factory:issue-label: <label> --> line names, null for none.

A decision is a file <number>.json in the decisions directory the listing printed, one per fact sheet, written with an editor or a here-document:

{
  "number": 123,
  "issue": "ABC-45",
  "issueSource": "branch",
  "issueSourceText": "tessl/abc-45",
  "decision": "trigger",
  "reason": "ABC-45 is delegated to Tessl and is In Review, a started state"
}

issue is the identifier upper-cased as a key of the fact sheet's issues, or null when there is none, and then issueSource and issueSourceText are null too. issueSource is branch or description; issueSourceText is copied exactly from the fact sheet: the branch, or the part of a description line that names the issue. decision is trigger or skip.

The trigger script checks every decision's shape before any call, then, unless the listing was a dry run, makes one tessl action trigger request for the fixer per trigger decision, passing the number as GITHUB_PR_NUMBER, under the id merge-conflict:<number>@<head sha> and linking the pull request's url as references, retrying a failed call with backoff for 30 minutes from its first attempt. The trigger route records an id once, so alreadyRecorded: true means a round was already requested for that head, and that is a success. Each request holds the key github:pr:<number> at limit 1, which shepherd's rounds on that pull request also hold as setup declares it, so its round waits behind them. It writes launch-result.json from the listing, the fact sheets, your decisions and the trigger outcomes, and prints one summary line: succeeded when every call succeeded, failed naming each pull request whose call failed otherwise.

Running a script. Each can run until close to its end time after a disruptive push. Run it as a long command:

  • If your shell tool ends background children when the call returns (Codex exec_command does), do not use &, nohup or ( … ) &. Start the script in a persistent session (a tty session you poll with write_stdin).
  • Otherwise run it in the background with its output going to a log file.
  • Every call that waits on it returns within four minutes: each poll under 230 seconds, or a loop that also stops after 230 seconds, such as timeout 230 bash -c 'until grep -q EXIT= /tmp/x.log; do sleep 3; done'. Then wait again while it is still running.
  • Do not move on until the script has exited.

Judgment

The issue comes from the branch and the description, applied to the fact sheet:

  • The branch names a GitHub issue when the whole branchSuffix is issue-<number>: issue-12 names #12, never a Linear ISSUE-12. It names a Linear issue when the whole branchSuffix is a Linear identifier, such as abc-45 or XYZ-123. fix-abc-45 or abc-45-retry names nothing.
  • The description names it in a line that begins with Closes, whatever its markup: Closes ABC-45, Closes [ABC-45](…), - closes abc-45, Closes #12. An identifier or number mentioned anywhere else, including later in a line that does not begin with Closes, is not the reference. When the Closes lines name more than one issue, skip, naming them.
  • Record a Linear issue as its upper-cased identifier and a GitHub issue as #<number>, the keys of issues.
  • For a GitHub issue, the branch and the description must both name it, the same number: the fixer refuses a pull request where they do not, so skip with what each names, recording the branch's as the issue when it names one and the description's otherwise. When one names a GitHub issue and the other a Linear issue, skip, naming both, recording the branch's as the issue. For a Linear issue, when both name one, compare them ignoring case: when they differ, skip, with both in the reason, recording the branch's as the issue; when only one names one, that is the issue.
  • When neither names one, skip with no issue.

Record the branch as the source when the branch names the issue, and the description otherwise. The trigger script refuses a branch source whose suffix is not the issue, and quoted text that does not contain it.

Eligible means, from the issue's entry in issues, that it is known and readable, and:

  • for a Linear issue: it is delegated to Tessl, which means its delegateId equals tesslAppLinearUserId. When tesslAppLinearUserId is null, no Linear issue is delegated to Tessl, even one whose delegateId is null. Its state is one of linearActiveStates, or, when that is null, its stateType is known and is neither completed nor canceled; and its hoursInState is under the limit ageLimitHours gives its state, when it gives one. A missing stateEnteredAt for a state with a limit is not eligible.
  • for a GitHub issue: its state is OPEN, and its labels include the issue label, which is issueLabelFromDescription when that is set and githubIssueLabel otherwise.

Anything else is a skip, and the reason names the fact that ruled it out: ABC-45 is in Backlog, ABC-45 is not delegated to Tessl, ABC-45 has been in In Review for 52.0 hours, over 24, #12 is closed, #12 does not carry the issue label tessl (naming the label in force).

Decide each pull request from its own fact sheet alone, one at a time. Read each fact sheet whole before you write its decision.

README.md

tile.json