Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/worker.go
T
jochen 11afde55d6
mesh/delivery-group group feat/399-fixing-status checking: 0 of 2 member(s) ready
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 superseded: a newer head of the same pull request
mesh-issues: an issue says who works on it, and resolves only from fixing (hq issue 399)
Two agents could start on one issue without seeing each other, and an issue
could be resolved without anyone having said they started its fix. A move to
diagnosing or fixing now names by, the worker as who@node; get and list show
the worker with since and stale; another worker is refused while it is
current unless take-over says why (noted); a worker untouched for
worker-stale-after (24h) may be taken over from without a reason. Resolved is
reached only from fixing. The moved event carries by, since and caller.
Fixing counts as not closed for the triage and the duplicate check.
hq ADR 0283.
2026-10-10 21:04:32 +02:00

147 lines
5.8 KiB
Go

package main
// Who works on an issue (novox/hq ADR 0283). 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: the runtime does not yet hand a module its caller (hq
// issue 340), so who acted stays recorded as the runtime proves it (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
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 0283 §1 and §2: 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)
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))
if o.wasStale {
s += " (stale)"
}
if reason != "" {
s += ". Why: " + reason
}
return s + ". (novox/hq ADR 0283)"
}