Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,10 @@ earlier snapshot belongs to the earlier launch.
Relaunch once with the relaunch notice: carry budgets over (reference.md "Transient stops"). Set
`repairs_spent` to the repairs in the stopped run's `final.repairs` plus the earlier value.
First recompute `done`, `reviewed`, `deferred`, the `carry` notes, `carried_minors`,
`inputs_changed`, the join wave fields and `backfill` as in Resume
steps 2-3, then rerun `scripts/setup --owner <token>` and take `start_points`; if validation
rejects its `setup_skipped`, rerun setup. Launch again. No second table. A second transient
stop is a real stop, unless it is a usage-limit stop (reference.md "Usage limit").
`inputs_changed`, the join wave fields and `backfill` as in Resume steps 2-3, then rerun
`scripts/setup --owner <token>`, put its output in `setup_result` and take `start_points`; if
validation rejects its `setup_skipped`, rerun setup. Launch again. No second table. A second
transient stop is a real stop, unless it is a usage-limit stop (reference.md "Usage limit").

## Notify

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.6.6
1.6.7
9 changes: 9 additions & 0 deletions manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,15 @@
"discarded": {
"$ref": "#/$defs/textList"
},
"git_identity": {
"description": "The run's git identity, from scripts/setup: the -c user.name=VALUE and user.email=VALUE pairs the commit rules name, user.name first, each key once; [] when the commit rules name none. The engine's merges and the commits it spells out pass them as -c options (merge-wave as --git-config).",
"type": "array",
"maxItems": 2,
"items": {
"type": "string",
"pattern": "^user\\.(name|email)=.+$"
}
},
"setup_skipped": {
"description": "Lane ids whose setup commands setup skipped because every task of the lane is in done and reviewed. A lane with no setup commands is never listed. validateManifest rejects a listed lane that has pending work.",
"type": "array",
Expand Down
131 changes: 103 additions & 28 deletions reference.md

Large diffs are not rendered by default.

158 changes: 125 additions & 33 deletions run.workflow.js

Large diffs are not rendered by default.

61 changes: 24 additions & 37 deletions scripts/_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@
the upstream tip is read with git ls-remote.

Hard failures (they block a launch):
- git_identity: git var GIT_COMMITTER_IDENT fails with user.useConfigOnly
set (so git does not invent user@host), after the -c user.name=... and
-c user.email=... pairs commit_rules passes.
- git_identity: git var GIT_COMMITTER_IDENT or git var GIT_AUTHOR_IDENT
fails with user.useConfigOnly set (so git does not invent user@host),
after the -c user.name=... and -c user.email=... pairs commit_rules passes
(_identity.identity_pairs). A commit needs both: a committer identity
alone (GIT_COMMITTER_NAME/EMAIL) still fails "Author identity unknown".
- base_not_current: the local repo.base_ref does not contain its upstream's
tip. The detail is behind (the base is an ancestor of the tip), diverged
(neither contains the other) or unknown (the tip is not in the local repo,
Expand Down Expand Up @@ -99,6 +101,7 @@
import tempfile
import time

import _identity
import _shell

# The skill's folder: the scripts folder's parent, which holds VERSION.
Expand Down Expand Up @@ -514,45 +517,29 @@ def _git_prefix(repo, probes):
return ["-C", root]


# -c NAME=VALUE in commit_rules: the pair quoted, NAME="VALUE", or a bare word.
_CONFIG_PAIR = re.compile(
r"""(?:^|[\s`(])-c\s+(?:"([^"]*)"|'([^']*)'|([\w.]+=(?:"[^"]*"|'[^']*'))|([^\s`]+))""")
_IDENTITY_KEYS = ("user.name=", "user.email=")


def identity_pairs(commit_rules):
"""The -c user.name=... and -c user.email=... pairs commit_rules passes."""
pairs = []
for m in _CONFIG_PAIR.finditer(commit_rules or ""):
if m.group(4) is not None:
# A bare word may end a sentence or a parenthesis.
pair = m.group(4).rstrip(".,;:)")
else:
pair = next(g for g in m.groups()[:3] if g is not None)
key, eq, value = pair.partition("=")
if eq and (key.lower() + "=") in _IDENTITY_KEYS and value.strip(" \"'"):
pairs.append(key + "=" + value.strip(" \"'"))
return pairs
# The commit rules' -c user.name/user.email pairs; kept here under its old
# name for any caller of _doctor.identity_pairs.
identity_pairs = _identity.identity_pairs


def check_git_identity(manifest, probes, prefix, report):
rules = manifest.get("commit_rules")
pairs = identity_pairs(rules if isinstance(rules, str) else "")
"""git commit needs both identities: the committer's, then the author's."""
args = list(prefix) if prefix else ["-C", tempfile.gettempdir()]
for pair in pairs:
for pair in identity_pairs(manifest.get("commit_rules")):
args += ["-c", pair]
args += ["-c", "user.useConfigOnly=true", "var", "GIT_COMMITTER_IDENT"]
res = probes.git(args)
if res.code == 0 and res.out.strip():
report.passed("git_identity")
return
report.fail(
"git_identity",
"git has no committer identity (git var GIT_COMMITTER_IDENT fails)",
"set one with git config --global user.name NAME and git config --global "
"user.email EMAIL, or name one that commit_rules passes as git -c "
"user.name=... -c user.email=... commit.",
)
args += ["-c", "user.useConfigOnly=true", "var"]
for who, var in (("committer", "GIT_COMMITTER_IDENT"), ("author", "GIT_AUTHOR_IDENT")):
res = probes.git(args + [var])
if res.code != 0 or not res.out.strip():
report.fail(
"git_identity",
"git has no " + who + " identity (git var " + var + " fails)",
"set one with git config --global user.name NAME and git config --global "
"user.email EMAIL, or name one that commit_rules passes as git -c "
"user.name=... -c user.email=... commit.",
)
return
report.passed("git_identity")


def _ancestor(probes, prefix, a, b):
Expand Down
48 changes: 48 additions & 0 deletions scripts/_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""The git identity a run's commit rules pass: -c user.name=... and -c user.email=...

Import with:
sys.dont_write_bytecode = True
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import _identity

identity_pairs(commit_rules) returns the -c pairs for user.name and
user.email that the text passes, as ["user.name=VALUE", "user.email=VALUE"]:
keys in lower case, at most one pair per key (the last one given wins),
user.name first. A pair is written -c "NAME=VALUE", -c 'NAME=VALUE',
-c NAME="VALUE" (or 'VALUE') or -c NAME=VALUE; a bare word loses a trailing
. , ; : or ) (it may end a sentence or a parenthesis). A pair with an empty
value is not a pair. Text with no such pair, or a value that is not a
string, gives [].

setup prints the result as setup_result.git_identity; doctor's git_identity
check passes the pairs to git var.
"""

import re
import sys

sys.dont_write_bytecode = True

# -c NAME=VALUE: the pair quoted, NAME="VALUE", or a bare word.
_CONFIG_PAIR = re.compile(
r"""(?:^|[\s`(])-c\s+(?:"([^"]*)"|'([^']*)'|([\w.]+=(?:"[^"]*"|'[^']*'))|([^\s`]+))""")
_IDENTITY_KEYS = ("user.name", "user.email")


def identity_pairs(commit_rules):
"""The -c user.name=... and -c user.email=... pairs commit_rules passes."""
if not isinstance(commit_rules, str):
return []
values = {}
for m in _CONFIG_PAIR.finditer(commit_rules):
if m.group(4) is not None:
# A bare word may end a sentence or a parenthesis.
pair = m.group(4).rstrip(".,;:)")
else:
pair = next(g for g in m.groups()[:3] if g is not None)
key, eq, value = pair.partition("=")
key = key.lower()
value = value.strip(" \"'")
if eq and key in _IDENTITY_KEYS and value:
values[key] = value
return [key + "=" + values[key] for key in _IDENTITY_KEYS if key in values]
50 changes: 39 additions & 11 deletions scripts/ledger
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,15 @@ and an 'event' of:
carried task _run only, with 'wf', 'pf' ({"PF-n": ruling id}) and
'bound' ([{file, line}]); carry-launch writes it last, once per
launch
wave_merged task _run only, with 'units': non-empty list of unit ids,
'head', and exactly one of 'wave' (integer >= 1) and
'recovery': true; optional 'overlaps' ([{path, units}]) and
'conflict_files' (paths); merge-wave writes it
wave_merged task _run only, with 'units': list of unit ids (in the
history after the merge; empty only with non-empty
'conflict_files' or a 'failed'), 'head', and exactly one of
'wave' (integer >= 1) and 'recovery': true; optional
'overlaps' ([{path, units}]), 'conflict_files' (paths), and
(1.6.7) 'merged' (ids within 'units' this event merged),
'conflict_unit' (id), 'failed' ({unit, error}, both
non-empty) and 'resolved' (true); merge-wave writes it, and
the wave resolver with 'resolved'
wave_checked task _run only, with 'wave' (integer >= 1), 'head' and 'ok'
(true or false); 'unfinished': true when the checks stopped
without a result (ok false)
Expand Down Expand Up @@ -121,7 +126,8 @@ join_started: join.jsonl holds a 'started' event (the join began in an
earlier launch: it then runs serially, 1.6.5). unit_branches: {task: {branch,
dir}} from each join task's latest 'started' event with a unit_branch, since
its latest restart. wave_merges: the _run wave_merged events in order, each
{wave or recovery, units, head, overlaps, conflict_files}. start_bases: {task:
{wave or recovery, units, head, overlaps, conflict_files}, plus merged,
conflict_unit, failed and resolved when recorded (1.6.7). start_bases: {task:
base} of each task not done, from its latest 'started' event. A 'started' event
with restart true begins its task afresh: its earlier events (committed,
reviewed, blocked, settled) no longer count.
Expand Down Expand Up @@ -224,7 +230,8 @@ its unfinished tasks are skipped. It also prints "join_tip": among the heads
of the wave_merged and wave_checked events and the backfill heads of join
tasks that did not run on a unit branch, the one every other is an ancestor
of (null when there is none); two heads neither of which descends from the
other are an error.
other are an error. A wave_merged event with an empty 'merged' (a conflict or
refusal before any merge) adds no head.

Exit 0 on success; 2 on usage error or a malformed entry; 3 on an unsafe lane
or task name, an unreadable ledger, plan or manifest, or a backfill error.
Expand Down Expand Up @@ -414,8 +421,19 @@ def wave_error(entry):
if not is_text(entry.get("head")):
return "'head' must be a non-empty string"
if entry["event"] == "wave_merged":
if not (isinstance(entry.get("units"), list) and entry["units"] and is_text_list(entry["units"])):
return "'units' must be a non-empty list of unit ids"
if not is_text_list(entry.get("units")):
return "'units' must be a list of unit ids"
if "merged" in entry and not (is_text_list(entry["merged"])
and all(u in entry["units"] for u in entry["merged"])):
return "'merged' must be a list of ids within 'units'"
if "conflict_unit" in entry and not is_text(entry["conflict_unit"]):
return "'conflict_unit' must be a non-empty string"
failed = entry.get("failed")
if "failed" in entry and not (isinstance(failed, dict) and is_text(failed.get("unit"))
and is_text(failed.get("error"))):
return "'failed' must be {unit, error}, both non-empty strings"
if "resolved" in entry and entry["resolved"] is not True:
return "'resolved' must be true when present"
has_wave, has_recovery = "wave" in entry, "recovery" in entry
if has_wave == has_recovery:
return "a wave_merged event has exactly one of 'wave' and 'recovery'"
Expand All @@ -429,6 +447,9 @@ def wave_error(entry):
return "'overlaps' must be a list of {path, units}"
if not is_text_list(entry.get("conflict_files", [])):
return "'conflict_files' must be a list of paths"
# Empty units (1.6.7): a conflict or refusal before any unit merged.
if not entry["units"] and not (entry.get("conflict_files") or "failed" in entry):
return "'units' may be empty only with non-empty 'conflict_files' or a 'failed'"
else:
if not is_count(entry.get("wave"), 1):
return "'wave' must be an integer >= 1"
Expand Down Expand Up @@ -799,7 +820,8 @@ def join_facts(events_at):
unit_branches ({task: {branch, dir}} from each join task's latest
started event with a unit_branch since its latest restart), and
wave_merges (the wave_merged events of _run.jsonl in order, as {wave or
recovery, units, head, overlaps, conflict_files})."""
recovery, units, head, overlaps, conflict_files}, plus merged,
conflict_unit, failed and resolved when the event has them)."""
started, branches, merges = False, {}, []
for name, _, entry in events_at:
event = entry["event"]
Expand All @@ -813,18 +835,24 @@ def join_facts(events_at):
item = {"wave": entry["wave"]} if "wave" in entry else {"recovery": True}
item.update({"units": entry["units"], "head": entry["head"],
"overlaps": entry.get("overlaps", []), "conflict_files": entry.get("conflict_files", [])})
item.update({k: entry[k] for k in ("merged", "conflict_unit", "failed", "resolved") if k in entry})
merges.append(item)
return {"join_started": started, "unit_branches": branches, "wave_merges": merges}


def join_tip(events, ranges, unit_branches, git):
"""The join's tip: among the heads of wave_merged and wave_checked
events and the recorded heads of join tasks that did not run on a unit
branch, the one every other is an ancestor of. Returns (tip or None,
error or None)."""
branch, the one every other is an ancestor of. A wave_merged event whose
merged is present and empty (a conflict or refusal before any merge,
1.6.7) records the checkout's head, not a join merge, and is skipped; one
with no merged field (before 1.6.7) counts. Returns (tip or None, error
or None)."""
heads = []
for entry in events:
if entry["task"] == "_run" and entry["event"] in ("wave_merged", "wave_checked"):
if entry["event"] == "wave_merged" and entry.get("merged") == []:
continue
heads.append(entry["head"])
for task, rng in ranges.items():
if task not in unit_branches:
Expand Down
Loading
Loading