mandrel 1.77.0 → 1.78.0
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/.agents/docs/workflows.md +19 -0
- package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
- package/.agents/schemas/loop-unit.schema.json +70 -0
- package/.agents/scripts/check-doc-links.js +24 -1
- package/.agents/scripts/check-loop-units.js +204 -0
- package/.agents/scripts/generate-workflows-doc.js +37 -4
- package/.agents/scripts/lib/close-validation/process.js +61 -5
- package/.agents/scripts/lib/close-validation/runner.js +17 -1
- package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
- package/.agents/scripts/lib/mandrel-catalog.js +36 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
- package/.agents/scripts/providers/github/branch-protection.js +1 -1
- package/.agents/scripts/providers/github/errors.js +53 -2
- package/.agents/scripts/providers/github/labels.js +1 -1
- package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
- package/.agents/scripts/run-lint.js +11 -0
- package/.agents/scripts/sync-claude-commands.js +112 -29
- package/.agents/scripts/update-maintainability-baseline.js +19 -76
- package/.agents/workflows/loops/README.md +65 -0
- package/.agents/workflows/loops/fix-failing-tests.md +74 -0
- package/.agents/workflows/loops/nightly-audit.md +71 -0
- package/.agents/workflows/loops/watch-ci.md +68 -0
- package/docs/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/.agents/scripts/providers/github/transient-retry.js +0 -62
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/loop-units/validate-loop-unit.js — loop-unit frontmatter validator.
|
|
3
|
+
*
|
|
4
|
+
* Parses a loop-unit markdown file's YAML frontmatter and AJV-validates
|
|
5
|
+
* it against `.agents/schemas/loop-unit.schema.json` (Ajv2020). Mirrors
|
|
6
|
+
* the validation pattern established by `lib/spec/loader.js` (Ajv2020 +
|
|
7
|
+
* ajv-formats + js-yaml, cached compiled validator, normalised
|
|
8
|
+
* `{ path, message }` issues).
|
|
9
|
+
*
|
|
10
|
+
* A "loop unit" is a markdown file under `.agents/workflows/loops/` whose
|
|
11
|
+
* leading `---`-fenced YAML frontmatter block defines a recurring unit of
|
|
12
|
+
* work (cadence, goal, conditional verify, round cap, exhaustion policy).
|
|
13
|
+
*
|
|
14
|
+
* Public surface:
|
|
15
|
+
* • `parseFrontmatter(source)` → extracts and YAML-parses the leading
|
|
16
|
+
* `---`-fenced block. Returns the parsed object (or `{}` for an empty
|
|
17
|
+
* block). Throws `LoopUnitParseError` when the block is absent or the
|
|
18
|
+
* YAML does not parse.
|
|
19
|
+
* • `validateLoopUnit(filePath, opts?)` → reads the file, parses its
|
|
20
|
+
* frontmatter, validates against the schema, and returns
|
|
21
|
+
* `{ valid, issues, data }`. `issues` is an array of
|
|
22
|
+
* `{ path, message }` (empty when valid). Never throws on a *validation*
|
|
23
|
+
* failure — it reports it via `valid: false` — but does throw
|
|
24
|
+
* `LoopUnitParseError` for an unreadable file or unparseable
|
|
25
|
+
* frontmatter so callers can distinguish "structurally broken file"
|
|
26
|
+
* from "schema-invalid unit".
|
|
27
|
+
*
|
|
28
|
+
* The module makes no GitHub calls and no process mutations; it is pure
|
|
29
|
+
* file I/O + schema validation. The `opts` bag accepts `{ schemaPath, fs }`
|
|
30
|
+
* so tests can point at a sandbox schema without monkey-patching globals.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
existsSync as defaultExistsSync,
|
|
35
|
+
readFileSync as defaultReadFileSync,
|
|
36
|
+
} from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
40
|
+
import addFormats from 'ajv-formats';
|
|
41
|
+
import yaml from 'js-yaml';
|
|
42
|
+
|
|
43
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
|
|
45
|
+
// scripts/lib/loop-units/ → scripts/lib/ → scripts/ → .agents/
|
|
46
|
+
const PROJECT_AGENTS_DIR = path.resolve(__dirname, '..', '..', '..');
|
|
47
|
+
export const DEFAULT_SCHEMA_PATH = path.join(
|
|
48
|
+
PROJECT_AGENTS_DIR,
|
|
49
|
+
'schemas',
|
|
50
|
+
'loop-unit.schema.json',
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const defaultFsAdapter = Object.freeze({
|
|
54
|
+
existsSync: defaultExistsSync,
|
|
55
|
+
readFileSync: defaultReadFileSync,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let cachedValidator = null;
|
|
59
|
+
let cachedValidatorKey = null;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compile (and cache) the Ajv2020 validator for the loop-unit schema.
|
|
63
|
+
* Cached by absolute schema path so tests can swap to a sandbox schema.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} schemaPath
|
|
66
|
+
* @param {{ readFileSync: typeof defaultReadFileSync }} fs
|
|
67
|
+
* @returns {(data: unknown) => boolean}
|
|
68
|
+
*/
|
|
69
|
+
function getValidator(schemaPath, fs) {
|
|
70
|
+
if (cachedValidator && cachedValidatorKey === schemaPath) {
|
|
71
|
+
return cachedValidator;
|
|
72
|
+
}
|
|
73
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
74
|
+
addFormats(ajv);
|
|
75
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
|
|
76
|
+
cachedValidator = ajv.compile(schema);
|
|
77
|
+
cachedValidatorKey = schemaPath;
|
|
78
|
+
return cachedValidator;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Raised when a loop-unit file cannot be read, has no YAML frontmatter
|
|
83
|
+
* block, or the frontmatter does not parse as YAML.
|
|
84
|
+
*/
|
|
85
|
+
export class LoopUnitParseError extends Error {
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} filePath
|
|
88
|
+
* @param {string} reason
|
|
89
|
+
*/
|
|
90
|
+
constructor(filePath, reason) {
|
|
91
|
+
super(`Loop unit ${filePath} could not be parsed: ${reason}`);
|
|
92
|
+
this.name = 'LoopUnitParseError';
|
|
93
|
+
this.filePath = filePath;
|
|
94
|
+
this.reason = reason;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Leading `---`-fenced YAML block. Tolerates CRLF and a leading BOM. The
|
|
99
|
+
// closing fence is a `---` (or `...`) on its own line.
|
|
100
|
+
const FRONTMATTER_RE = /^?---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\s*(?:\r?\n|$)/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract and YAML-parse the leading `---`-fenced frontmatter block from a
|
|
104
|
+
* markdown source string.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} source raw file contents
|
|
107
|
+
* @param {string} [filePath] used only for error messages
|
|
108
|
+
* @returns {object} the parsed frontmatter object (`{}` if empty)
|
|
109
|
+
* @throws {LoopUnitParseError} when no fence is present or the YAML fails
|
|
110
|
+
*/
|
|
111
|
+
export function parseFrontmatter(source, filePath = '<string>') {
|
|
112
|
+
const match = FRONTMATTER_RE.exec(source);
|
|
113
|
+
if (!match) {
|
|
114
|
+
throw new LoopUnitParseError(
|
|
115
|
+
filePath,
|
|
116
|
+
'no YAML frontmatter block (expected a leading "---" fence)',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = yaml.load(match[1], { filename: filePath });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
throw new LoopUnitParseError(
|
|
124
|
+
filePath,
|
|
125
|
+
`frontmatter is not valid YAML: ${err.message}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (parsed == null) return {};
|
|
129
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
130
|
+
throw new LoopUnitParseError(
|
|
131
|
+
filePath,
|
|
132
|
+
'frontmatter must be a YAML mapping',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Convert Ajv's error array into a `{ path, message }` shape. For
|
|
140
|
+
* `required` errors Ajv leaves the missing property in
|
|
141
|
+
* `params.missingProperty` rather than the instance path, so we append it
|
|
142
|
+
* so the caller sees `/loop/verify` instead of `/loop` and the message
|
|
143
|
+
* names the missing field.
|
|
144
|
+
*
|
|
145
|
+
* @param {Array<{instancePath:string,message:string,keyword:string,params?:Record<string,unknown>}>} ajvErrors
|
|
146
|
+
* @returns {Array<{path:string,message:string}>}
|
|
147
|
+
*/
|
|
148
|
+
function normaliseAjvErrors(ajvErrors) {
|
|
149
|
+
return (ajvErrors ?? []).map((err) => {
|
|
150
|
+
let p = err.instancePath || '/';
|
|
151
|
+
let message = err.message ?? 'validation failed';
|
|
152
|
+
if (
|
|
153
|
+
err.keyword === 'required' &&
|
|
154
|
+
typeof err.params?.missingProperty === 'string'
|
|
155
|
+
) {
|
|
156
|
+
const sep = p === '/' ? '' : '/';
|
|
157
|
+
p = `${p}${sep}${err.params.missingProperty}`;
|
|
158
|
+
message = `must have required property '${err.params.missingProperty}'`;
|
|
159
|
+
}
|
|
160
|
+
return { path: p, message };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read, parse, and schema-validate a loop-unit markdown file.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} filePath
|
|
168
|
+
* @param {{ schemaPath?: string, fs?: typeof defaultFsAdapter }} [opts]
|
|
169
|
+
* @returns {{ valid: boolean, issues: Array<{path:string,message:string}>, data: object }}
|
|
170
|
+
* @throws {LoopUnitParseError} when the file is unreadable or its
|
|
171
|
+
* frontmatter is missing/unparseable.
|
|
172
|
+
*/
|
|
173
|
+
export function validateLoopUnit(filePath, opts = {}) {
|
|
174
|
+
const fs = opts.fs ?? defaultFsAdapter;
|
|
175
|
+
const schemaPath = opts.schemaPath ?? DEFAULT_SCHEMA_PATH;
|
|
176
|
+
|
|
177
|
+
if (!fs.existsSync(filePath)) {
|
|
178
|
+
throw new LoopUnitParseError(filePath, 'file does not exist');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let raw;
|
|
182
|
+
try {
|
|
183
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new LoopUnitParseError(filePath, `unreadable: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const data = parseFrontmatter(raw, filePath);
|
|
189
|
+
const validate = getValidator(schemaPath, fs);
|
|
190
|
+
const ok = validate(data);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
valid: ok,
|
|
194
|
+
issues: ok ? [] : normaliseAjvErrors(validate.errors),
|
|
195
|
+
data,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -140,6 +140,42 @@ export function buildCatalog(workflowsDir) {
|
|
|
140
140
|
return catalog;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Build the loop-unit catalog from a workflows directory's `loops/`
|
|
145
|
+
* namespace. Loop units live at `.agents/workflows/loops/<name>.md` and
|
|
146
|
+
* project to the namespaced `/loops:<name>` slash command (Story #4289).
|
|
147
|
+
* They are catalogued separately from the flat top-level commands because
|
|
148
|
+
* they carry a distinct invocation form.
|
|
149
|
+
*
|
|
150
|
+
* Returns an empty array when the `loops/` subdirectory is absent (the
|
|
151
|
+
* common case before the starter loops land in a later Story) — an absent
|
|
152
|
+
* namespace is a clean "no loop units", not an error.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} workflowsDir — absolute path to `.agents/workflows/`.
|
|
155
|
+
* @returns {Array<{ name: string, description: string | null, vague: boolean }>}
|
|
156
|
+
*/
|
|
157
|
+
export function buildLoopCatalog(workflowsDir) {
|
|
158
|
+
const loopsDir = path.join(workflowsDir, 'loops');
|
|
159
|
+
if (!fs.existsSync(loopsDir)) return [];
|
|
160
|
+
const entries = fs.readdirSync(loopsDir, { withFileTypes: true });
|
|
161
|
+
const catalog = [];
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (!entry.isFile()) continue;
|
|
164
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
165
|
+
if (entry.name === 'README.md') continue;
|
|
166
|
+
const filePath = path.join(loopsDir, entry.name);
|
|
167
|
+
const source = fs.readFileSync(filePath, 'utf8');
|
|
168
|
+
const description = extractDescription(source);
|
|
169
|
+
catalog.push({
|
|
170
|
+
name: entry.name.replace(/\.md$/, ''),
|
|
171
|
+
description,
|
|
172
|
+
vague: isVagueDescription(description),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catalog.sort((a, b) => a.name.localeCompare(b.name));
|
|
176
|
+
return catalog;
|
|
177
|
+
}
|
|
178
|
+
|
|
143
179
|
/**
|
|
144
180
|
* Render the catalog as a plain-markdown bullet list. Kept as a
|
|
145
181
|
* lightweight alternative rendering of the same catalog backend that
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emit-loop-tick.js — Story #4287 (Epic #4284).
|
|
3
|
+
*
|
|
4
|
+
* Programmatic helper that emits a single `loop.tick` lifecycle event
|
|
5
|
+
* THROUGH the lifecycle bus so a host-driven loop (e.g. a `/loop`-style
|
|
6
|
+
* recurring command or a long-running poll) lands a per-pass record in
|
|
7
|
+
* the on-disk ledger the `/deliver` idle watchdog already scans. The
|
|
8
|
+
* record is what keeps a host loop from running silently: each round
|
|
9
|
+
* appends an inspectable `emitted` line a reconciler can read for
|
|
10
|
+
* forward-progress evidence.
|
|
11
|
+
*
|
|
12
|
+
* Distinct from `story.heartbeat` (emit-story-heartbeat.js): the
|
|
13
|
+
* heartbeat carries Story-phase info for a single in-flight Story and is
|
|
14
|
+
* always Epic-scoped (its ledger path is `epicLedgerPath(epicId)`). A
|
|
15
|
+
* host loop is not bound to a Story tier, so `loop.tick` carries a
|
|
16
|
+
* free-form `loopName`, a monotonic `round` counter, the loop's
|
|
17
|
+
* configured `cadence` label, and a per-round `status` instead. Keeping
|
|
18
|
+
* the two events separate means a loop tick never masquerades as Story
|
|
19
|
+
* progress (and vice versa).
|
|
20
|
+
*
|
|
21
|
+
* Bus path (Story acceptance: "Emitting a loop.tick event THROUGH the
|
|
22
|
+
* lifecycle bus appends a record to the per-run ledger"): this helper
|
|
23
|
+
* constructs a `Bus`, registers a `LedgerWriter` against it, and calls
|
|
24
|
+
* `bus.emit('loop.tick', payload)`. The bus validates the payload against
|
|
25
|
+
* `loop.tick.schema.json` before any listener runs, and the
|
|
26
|
+
* LedgerWriter's privileged `onEmitted` hook lands the `emitted` record
|
|
27
|
+
* on disk — exactly the same persistence path every other lifecycle
|
|
28
|
+
* event flows through. The helper does NOT bypass the bus with a direct
|
|
29
|
+
* `appendFileSync`; routing through the bus is what gives the record its
|
|
30
|
+
* schema-validated, seqId-stamped guarantee.
|
|
31
|
+
*
|
|
32
|
+
* Schema contract (loop.tick.schema.json):
|
|
33
|
+
* { event, loopName, round, cadence, status, timestamp }
|
|
34
|
+
*
|
|
35
|
+
* The schema declares `additionalProperties: false`, so this emitter's
|
|
36
|
+
* signature is deliberately narrow: only the schema-allowed fields are
|
|
37
|
+
* accepted. `status` is one of running|done|blocked.
|
|
38
|
+
*
|
|
39
|
+
* Ledger path resolution: a caller supplies EITHER an explicit
|
|
40
|
+
* `ledgerPath` (the host-loop case — the loop owns where its ledger
|
|
41
|
+
* lives) OR an `epicId`, in which case the canonical
|
|
42
|
+
* `epicLedgerPath(epicId)` is used so an Epic-scoped loop's ticks land
|
|
43
|
+
* in the same `temp/epic-<id>/lifecycle.ndjson` the rest of the run
|
|
44
|
+
* reads. Exactly one of the two MUST be supplied.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
|
|
50
|
+
import { epicLedgerPath } from '../../config/temp-paths.js';
|
|
51
|
+
import { createBus } from './bus.js';
|
|
52
|
+
import { createLedgerWriter } from './ledger-writer.js';
|
|
53
|
+
|
|
54
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const SCHEMA_DIR = path.resolve(
|
|
56
|
+
__dirname,
|
|
57
|
+
'..',
|
|
58
|
+
'..',
|
|
59
|
+
'..',
|
|
60
|
+
'..',
|
|
61
|
+
'schemas',
|
|
62
|
+
'lifecycle',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const VALID_STATUSES = new Set(['running', 'done', 'blocked']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse `temp/epic-<id>/lifecycle.ndjson` (or any
|
|
69
|
+
* `<dir>/epic-<id>/lifecycle.ndjson`) back into `{ tempRoot, epicId }`
|
|
70
|
+
* so a LedgerWriter — which is constructed from `{ epicId, tempRoot }`
|
|
71
|
+
* rather than a raw path — can be bound to the supplied ledger path.
|
|
72
|
+
*
|
|
73
|
+
* The LedgerWriter intentionally re-derives the ledger path from its
|
|
74
|
+
* `tempRoot` + `epicId` (so it can recreate the directory if a listener
|
|
75
|
+
* moves it mid-run), so we decompose the path the caller gave us into
|
|
76
|
+
* those two parts here.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} ledgerPath
|
|
79
|
+
* @returns {{ tempRoot: string, epicId: number }}
|
|
80
|
+
*/
|
|
81
|
+
function decomposeLedgerPath(ledgerPath) {
|
|
82
|
+
const epicDir = path.dirname(ledgerPath);
|
|
83
|
+
const tempRoot = path.dirname(epicDir);
|
|
84
|
+
const epicDirName = path.basename(epicDir);
|
|
85
|
+
const m = /^epic-(\d+)$/.exec(epicDirName);
|
|
86
|
+
if (!m) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`emitLoopTick: ledgerPath does not match <tempRoot>/epic-<id>/lifecycle.ndjson layout (got ${ledgerPath})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const epicId = Number.parseInt(m[1], 10);
|
|
92
|
+
return { tempRoot, epicId };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Emit exactly one `loop.tick` event through the lifecycle bus, landing
|
|
97
|
+
* an `emitted` (and `completed`) NDJSON record in the resolved ledger.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} opts
|
|
100
|
+
* @param {string} opts.loopName Free-form loop identifier (non-empty).
|
|
101
|
+
* @param {number} opts.round Monotonic pass counter (integer >= 0).
|
|
102
|
+
* @param {string} opts.cadence Configured interval label, e.g. '5m'.
|
|
103
|
+
* @param {string} [opts.status='running']
|
|
104
|
+
* One of running|done|blocked.
|
|
105
|
+
* @param {string} [opts.timestamp] ISO-8601 wall clock. Defaults to now().
|
|
106
|
+
* @param {number} [opts.epicId] When supplied (and no `ledgerPath`),
|
|
107
|
+
* the canonical `epicLedgerPath(epicId)`
|
|
108
|
+
* is used for the ledger.
|
|
109
|
+
* @param {object} [opts.config] Optional resolved config for tempRoot
|
|
110
|
+
* (only consulted on the `epicId` path).
|
|
111
|
+
* @param {string} [opts.ledgerPath] Explicit ledger path (host-loop case).
|
|
112
|
+
* Mutually exclusive with `epicId`.
|
|
113
|
+
* @returns {Promise<{ ledgerPath: string, payload: object, seqId: number }>}
|
|
114
|
+
*/
|
|
115
|
+
export async function emitLoopTick(opts) {
|
|
116
|
+
const {
|
|
117
|
+
loopName,
|
|
118
|
+
round,
|
|
119
|
+
cadence,
|
|
120
|
+
status = 'running',
|
|
121
|
+
timestamp = new Date().toISOString(),
|
|
122
|
+
epicId,
|
|
123
|
+
config,
|
|
124
|
+
ledgerPath: ledgerPathOverride,
|
|
125
|
+
} = opts ?? {};
|
|
126
|
+
|
|
127
|
+
if (typeof loopName !== 'string' || loopName.length === 0) {
|
|
128
|
+
throw new Error('emitLoopTick: loopName must be a non-empty string');
|
|
129
|
+
}
|
|
130
|
+
if (!Number.isInteger(round) || round < 0) {
|
|
131
|
+
throw new Error('emitLoopTick: round must be a non-negative integer');
|
|
132
|
+
}
|
|
133
|
+
if (typeof cadence !== 'string' || cadence.length === 0) {
|
|
134
|
+
throw new Error('emitLoopTick: cadence must be a non-empty string');
|
|
135
|
+
}
|
|
136
|
+
if (!VALID_STATUSES.has(status)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`emitLoopTick: status "${status}" must be one of: ${[...VALID_STATUSES].join(', ')}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const hasEpicId = epicId !== undefined;
|
|
143
|
+
const hasLedgerPath = ledgerPathOverride !== undefined;
|
|
144
|
+
if (hasEpicId === hasLedgerPath) {
|
|
145
|
+
throw new Error('emitLoopTick: supply exactly one of epicId or ledgerPath');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let ledgerPath;
|
|
149
|
+
if (hasLedgerPath) {
|
|
150
|
+
if (
|
|
151
|
+
typeof ledgerPathOverride !== 'string' ||
|
|
152
|
+
ledgerPathOverride.length === 0
|
|
153
|
+
) {
|
|
154
|
+
throw new Error('emitLoopTick: ledgerPath must be a non-empty string');
|
|
155
|
+
}
|
|
156
|
+
ledgerPath = ledgerPathOverride;
|
|
157
|
+
} else {
|
|
158
|
+
if (!Number.isInteger(epicId) || epicId < 1) {
|
|
159
|
+
throw new Error('emitLoopTick: epicId must be a positive integer');
|
|
160
|
+
}
|
|
161
|
+
ledgerPath = epicLedgerPath(epicId, config);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const payload = {
|
|
165
|
+
event: 'loop.tick',
|
|
166
|
+
loopName,
|
|
167
|
+
round,
|
|
168
|
+
cadence,
|
|
169
|
+
status,
|
|
170
|
+
timestamp,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Route through the bus so the payload is schema-validated and the
|
|
174
|
+
// LedgerWriter's privileged onEmitted hook persists the record — the
|
|
175
|
+
// same path every lifecycle event flows through.
|
|
176
|
+
const { tempRoot, epicId: ledgerEpicId } = decomposeLedgerPath(ledgerPath);
|
|
177
|
+
const bus = createBus({ schemaDir: SCHEMA_DIR });
|
|
178
|
+
const writer = createLedgerWriter({ epicId: ledgerEpicId, tempRoot });
|
|
179
|
+
writer.register(bus);
|
|
180
|
+
|
|
181
|
+
const { seqId } = await bus.emit('loop.tick', payload);
|
|
182
|
+
return { ledgerPath: writer.ledgerPath, payload, seqId };
|
|
183
|
+
}
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { withTransientRetry } from './errors.js';
|
|
16
17
|
import { parseApiJson } from './request-helpers.js';
|
|
17
|
-
import { withTransientRetry } from './transient-retry.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Detect a 404 across both error surfaces:
|
|
@@ -48,6 +48,40 @@ const TRANSIENT_MESSAGES = [
|
|
|
48
48
|
|
|
49
49
|
const PERMISSION_MESSAGES = ['unauthorized', 'forbidden', 'permission'];
|
|
50
50
|
|
|
51
|
+
// Network/connectivity blips that the gh-CLI path surfaces on `err.stderr`
|
|
52
|
+
// (Go HTTP errors, e.g. `dial tcp ...: i/o timeout`) and the direct `fetch`
|
|
53
|
+
// path surfaces as `TypeError: fetch failed` with the real reason on
|
|
54
|
+
// `err.cause` (e.g. `ETIMEDOUT`, `ENOTFOUND`). Folded in from the former
|
|
55
|
+
// `transient-retry.js` predicate (Story #4298) so the single canonical
|
|
56
|
+
// classifier retries the **union** of transient HTTP statuses/codes AND
|
|
57
|
+
// transient network errors. The `\b50[234]\b` alternative also catches a
|
|
58
|
+
// bare 502/503/504 surfaced only in an error message string (no `.status`).
|
|
59
|
+
const TRANSIENT_NETWORK_RE =
|
|
60
|
+
/i\/o timeout|dial tcp|TLS handshake timeout|connection reset|connection refused|temporary failure|could not resolve host|no such host|network is unreachable|socket hang up|fetch failed|ConnectTimeoutError|UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|\b50[234]\b/i;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True when an error looks like a retryable network/connectivity blip.
|
|
64
|
+
* Scans the union of fields both transport paths populate (`stderr`,
|
|
65
|
+
* `message`, `code`, and the nested `cause.message` / `cause.code` the
|
|
66
|
+
* `fetch` path uses). Module-private — folded in from the former
|
|
67
|
+
* `transient-retry.js` predicate (Story #4298) and consumed only by
|
|
68
|
+
* `classifyGithubError` below; its behavior is exercised through that public
|
|
69
|
+
* classifier rather than as a standalone export (keeps the dead-export gate
|
|
70
|
+
* green — nothing outside this module imports it).
|
|
71
|
+
*/
|
|
72
|
+
function isTransientNetworkError(err) {
|
|
73
|
+
const hay = [
|
|
74
|
+
err?.stderr,
|
|
75
|
+
err?.message,
|
|
76
|
+
err?.code,
|
|
77
|
+
err?.cause?.message,
|
|
78
|
+
err?.cause?.code,
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join(' ');
|
|
82
|
+
return TRANSIENT_NETWORK_RE.test(hay);
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
function matchesAny(haystack, needles) {
|
|
52
86
|
for (const n of needles) if (haystack.includes(n)) return true;
|
|
53
87
|
return false;
|
|
@@ -97,19 +131,36 @@ export function classifyGithubError(err) {
|
|
|
97
131
|
if (matchesAny(lower, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
|
|
98
132
|
if (isTransientStatus(status)) return 'transient';
|
|
99
133
|
if (isTransientByCodeOrMessage(code, lower)) return 'transient';
|
|
134
|
+
// Union with the former `transient-retry.js` predicate (Story #4298):
|
|
135
|
+
// retry on network/connectivity blips the status/code checks above miss
|
|
136
|
+
// (e.g. a `dial tcp ... i/o timeout` on `err.stderr` from the gh-CLI path,
|
|
137
|
+
// or `ECONNREFUSED` / `ENETUNREACH`). Checked before the permission rule so
|
|
138
|
+
// a transient network failure never masquerades as a permanent denial.
|
|
139
|
+
if (isTransientNetworkError(err)) return 'transient';
|
|
100
140
|
if (isPermissionSignal(status, lower)) return 'permission';
|
|
101
141
|
return 'permanent';
|
|
102
142
|
}
|
|
103
143
|
|
|
104
144
|
// ---------------------------------------------------------------------------
|
|
105
|
-
// Transient-retry helper (Story #2852)
|
|
145
|
+
// Transient-retry helper (Story #2852; unified in Story #4298)
|
|
106
146
|
// ---------------------------------------------------------------------------
|
|
107
147
|
//
|
|
148
|
+
// The single canonical `withTransientRetry` for the GitHub provider. Story
|
|
149
|
+
// #4298 collapsed the former two divergent same-named implementations (this
|
|
150
|
+
// one + the network-only one in the deleted `transient-retry.js`) into this
|
|
151
|
+
// one primitive. Its default classifier (`classifyGithubError`) is the
|
|
152
|
+
// **union** predicate — it retries on transient HTTP statuses/codes AND on
|
|
153
|
+
// transient network/connectivity errors — so every former consumer of either
|
|
154
|
+
// module keeps (or gains) its prior retry coverage with no shim.
|
|
155
|
+
//
|
|
108
156
|
// Mirrors the addSubIssue retry contract in `sub-issues.js` so read-path
|
|
109
157
|
// callers (paginateRest, getTicket, getNativeSubIssues, …) absorb the same
|
|
110
158
|
// jittered exponential backoff on transient GitHub errors instead of
|
|
111
159
|
// bubbling a one-shot 502/429/ECONNRESET that kills a longer pipeline
|
|
112
|
-
// (e.g. the /deliver Phase E retro).
|
|
160
|
+
// (e.g. the /deliver Phase E retro). The network consumers repointed here
|
|
161
|
+
// (branch-protection, labels, projects-v2-graphql) call with no opts, so
|
|
162
|
+
// they adopt these defaults; their retry *classes* (the network blips) are
|
|
163
|
+
// preserved via the unified classifier above.
|
|
113
164
|
|
|
114
165
|
export const TRANSIENT_RETRY_DEFAULTS = Object.freeze({
|
|
115
166
|
maxAttempts: 6,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { withTransientRetry } from './
|
|
19
|
+
import { withTransientRetry } from './errors.js';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Detect the "label already exists" signal across the surfaces `gh label
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `unavailable:true` envelopes. Wave 3 deletes the old submodules.
|
|
8
8
|
*/
|
|
9
9
|
import { execSync } from 'node:child_process';
|
|
10
|
-
import { withTransientRetry } from './
|
|
10
|
+
import { withTransientRetry } from './errors.js';
|
|
11
11
|
|
|
12
12
|
// Resolve an owner node id per-scope. Querying `user` and `organization`
|
|
13
13
|
// together in one request makes GitHub return a NOT_FOUND error for whichever
|
|
@@ -78,6 +78,17 @@ const tasks = [
|
|
|
78
78
|
cmd: 'node',
|
|
79
79
|
args: ['.agents/scripts/check-arch-cycles.js'],
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
// Loop-unit frontmatter gate (Story #4288, Epic #4284). Validates
|
|
83
|
+
// every `.agents/workflows/loops/*.md` loop unit against
|
|
84
|
+
// `.agents/schemas/loop-unit.schema.json`. An absent/empty loops
|
|
85
|
+
// directory is a clean pass; a malformed unit (e.g. a self-paced
|
|
86
|
+
// cadence missing its required `verify`) fails the lint gate with a
|
|
87
|
+
// message naming the offending file + field.
|
|
88
|
+
name: 'loop-units',
|
|
89
|
+
cmd: 'node',
|
|
90
|
+
args: ['.agents/scripts/check-loop-units.js'],
|
|
91
|
+
},
|
|
81
92
|
];
|
|
82
93
|
|
|
83
94
|
function runTask({ name, cmd, args }) {
|