Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/worker.go
T
jschoubben d6148a5ba7
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh-issues: call an issue its worker left abandoned, not a stale claim (hq ADR 0294)
The glossary keeps claim for a module's on a seat.
2026-10-11 12:26:57 +02:00

153 lines
6.1 KiB
Go

package main
// Who works on an issue (novox/hq ADR 0284). A move to diagnosing or fixing names, in `by`, the issue's worker:
// the agent or person and its node, as who@node. The issue keeps its worker while it stays at either status, and
// `get` and `list` show it with since. A move to either by someone else while the worker is not stale is refused,
// naming the worker; a take-over gives a reason, and the reason is noted. A worker with no move, note, link or set
// from a caller for `worker-stale-after` (24 hours) is stale: `list` says so, and it may be taken over without a
// reason.
//
// `by` is what the worker says it is, not who called: who acted is recorded beside it as the runtime proves it
// (the caller's bus user, hq issue 365, or Unproven), and the worker is a way for agents and people to see each
// other, not a lock against someone giving a false name.
import (
"fmt"
"regexp"
"strings"
"time"
)
// Worker is who works on an issue, since when, and when a caller last did anything on it.
type Worker struct {
By string `json:"by"`
Since time.Time `json:"since"`
// Active is the last move, note, link or set on the issue by a caller, not by the tracker itself.
Active time.Time `json:"active"`
}
// DefaultWorkerStaleAfter is how long a worker stays current with nothing done on its issue: a working day and a
// night, so an agent that stopped (its session ended, its machine asleep) frees the issue by the next day, and
// one that works on it keeps it by noting what it found.
const DefaultWorkerStaleAfter = 24 * time.Hour
// MostWorker is the most a worker's name, who@node, may be.
const MostWorker = 120
// workerName is who@node: who is the agent or person (letters, digits and . _ / -, such as fix-399 or jochen),
// node the machine it works on.
var workerName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*@[a-z0-9][a-z0-9-]*$`)
// worked says whether an issue at a status has a worker: at diagnosing and fixing.
func worked(status string) bool { return status == StatusDiagnosing || status == StatusFixing }
// checkWorker is `by` as kept: who@node, in lower case.
func checkWorker(s string) (string, error) {
s, err := singleLine("by", s, MostWorker)
if err != nil {
return "", err
}
s = strings.ToLower(s)
if !workerName.MatchString(s) {
return "", fmt.Errorf("by %q is not who works on it and its node: give who@node, the agent or person and the "+
"machine it works on, such as fix-399@g14 or jochen@shanks", s)
}
return s, nil
}
// ownActor says whether a write is the tracker's own (the triage, the duplicate check, the delivery's notes, the
// import), which keeps no worker current.
func ownActor(by string) bool { return by == GroomBy || by == LegacyBy || by == ImportedBy }
// touch marks a caller's write on an issue as activity of its worker.
func touch(is *Issue, by string, at time.Time) {
if is.Worker != nil && !ownActor(by) {
is.Worker.Active = at
}
}
// stale says whether nothing was done on the issue past the bound since the worker last was.
func (w *Worker) stale(now time.Time, after time.Duration) bool {
last := w.Active
if last.Before(w.Since) {
last = w.Since
}
return now.Sub(last) > after
}
// workerStaleAfter is the setting `worker-stale-after`, or DefaultWorkerStaleAfter when it is empty or unreadable.
func (t *Tracker) workerStaleAfter() time.Duration {
if s, err := t.Settings(); err == nil && strings.TrimSpace(s.WorkerStaleAfter) != "" {
if d, err := time.ParseDuration(strings.TrimSpace(s.WorkerStaleAfter)); err == nil && d > 0 {
return d
}
}
return DefaultWorkerStaleAfter
}
// workOutcome is what a move did to an issue's worker.
type workOutcome struct {
took bool // a worker started, or the issue changed hands
from string // the worker it was taken over from
wasStale bool // the worker taken over from was stale
// abandoned is a stale worker with no pull request open: the issue is abandoned (novox/hq issue 483).
abandoned bool
released string // the worker whose work ended, on a move to a status that has none
}
// workerFor applies a move's worker to an issue, under ADR 0284 §3 and §4: another worker not stale is refused
// without a take-over reason; a take-over reason with no other worker to take over from is refused.
func workerFor(is *Issue, to, by, takeOver string, now time.Time, staleAfter time.Duration) (workOutcome, error) {
var out workOutcome
cur := is.Worker
if !worked(to) {
if cur != nil {
out.released = cur.By
is.Worker = nil
}
return out, nil
}
if cur != nil && cur.By == by {
if takeOver != "" {
return out, fmt.Errorf("%s works on issue %d already: take-over is for taking it over from someone else", by, is.Number)
}
cur.Active = now
return out, nil
}
if cur == nil {
if takeOver != "" {
return out, fmt.Errorf("nobody works on issue %d: take-over is for taking it over from someone else; move it without one", is.Number)
}
is.Worker = &Worker{By: by, Since: now, Active: now}
out.took = true
return out, nil
}
out.wasStale = cur.stale(now, staleAfter)
out.abandoned = out.wasStale && !openPullRequest(is)
if !out.wasStale && takeOver == "" {
return out, fmt.Errorf("%s works on issue %d (%s) since %s, last active %s. Ask them, or take it over with "+
"take-over saying why; the reason is noted on the issue. A worker who did nothing on it for %s may be "+
"taken over from without a reason", cur.By, is.Number, is.Status, cur.Since.Format(time.RFC3339),
cur.Active.Format(time.RFC3339), staleAfter)
}
out.from, out.took = cur.By, true
is.Worker = &Worker{By: by, Since: now, Active: now}
return out, nil
}
// takeOverNote is the note a take-over leaves on the issue.
func takeOverNote(o workOutcome, by, reason string, prev *Worker) string {
s := fmt.Sprintf("Taken over by %s from %s, who worked on it since %s and was last active %s", by, o.from,
prev.Since.Format(time.RFC3339), prev.Active.Format(time.RFC3339))
switch {
case o.abandoned:
s += " (stale, and abandoned: no pull request open)"
case o.wasStale:
s += " (stale)"
}
if reason != "" {
s += ". Why: " + reason
}
return s + ". (novox/hq ADR 0284)"
}