pi-crew 0.9.65 → 0.9.66
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/CHANGELOG.md +18 -0
- package/dist/index.mjs +107 -31
- package/package.json +1 -1
- package/src/config/config.ts +19 -3
- package/src/config/types.ts +2 -0
- package/src/extension/team-tool/cancel.ts +34 -0
- package/src/extension/team-tool/dispatch/manage.ts +7 -4
- package/src/extension/team-tool/explain.ts +3 -1
- package/src/extension/team-tool/lifecycle-actions.ts +4 -1
- package/src/extension/team-tool-types.ts +2 -0
- package/src/runtime/output/output-validator.ts +34 -6
- package/src/runtime/scheduling/scheduler.ts +67 -19
- package/scripts/analyze-run.mjs +0 -1333
- package/scripts/resource-sampler.mjs +0 -482
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
> **Note:** `atomic-write-v2.ts` / `AtomicWriter` mentioned in historical entries below was consolidated into `atomic-write.ts` as of v0.9.42. This changelog is preserved as historical record — the migration was completed (the v2 class was never adopted; v1 won on simplicity + symlink-safety + link+unlink atomicity). See `docs/migration/atomic-write-v2-migration.md` for the decision rationale.
|
|
4
4
|
|
|
5
|
+
## [0.9.66] — real-test findings: cross-project run lookup, output validation, config no-op-write, retry clarity, cron grammar (2026-08-10)
|
|
6
|
+
|
|
7
|
+
Six fixes distilled from the `real-test-pi-crew` 9-tier battery (run 2026-08-10). All verified live end-to-end; `npm run test:critical` 101/101, `npx tsc --noEmit` exit 0, biome lint+format clean.
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
- **Cross-project run lookup** (`src/extension/team-tool/explain.ts`, `lifecycle-actions.ts`): `explain` and `worktrees` now resolve runs via `locateRunCwd` (the same cross-project resolver `status`/`cancel`/`inspect` use) instead of raw `ctx.cwd`. A run created with a nested `cwd` override is now reachable from the parent session root (previously "Run not found"). Finding #1.
|
|
11
|
+
- **Output validation accepts markdown** (`src/runtime/output/output-validator.ts`): `ROLE_PATTERN_DEFS` only matched the strict caveman format, so every task tripped `output_validation valid:false`. Each role pattern now also accepts markdown-structured handoffs (`## Handoff`, bullets, bold); structural-preservation checks and empty-output rejection are unchanged. Finding #2.
|
|
12
|
+
- **Config skip-write guard** (`src/config/config.ts`, `types.ts`): `action='config'` with an empty patch went through the write path (`parseConfig({})` yields a full default config → `shouldUpdate=true`) and rewrote `~/.pi/agent/pi-crew.json` on every read. Added a skip-if-unchanged guard (and a `written` flag) so a no-op patch leaves the file untouched. Finding #3.
|
|
13
|
+
- **Retry clarity for completed runs** (`src/extension/team-tool/cancel.ts`): `action='retry'` on a completed run acquired the run lock and surfaced a stale-lock error ("run.lock is locked by another operation"). A pre-lock terminal-status check now short-circuits to "already completed; retry only applies to failed/cancelled runs" before touching the lock. Finding #4.
|
|
14
|
+
- **Cron grammar** (`src/runtime/scheduling/scheduler.ts`): `nextCronDate`'s matcher rejected standard cron step values (`*/30`, `9-17/2`) and named tokens (`MON`, `JAN`), so `action='schedule cron='0 9 * * MON'` errored with "No next cron occurrence found". Rewritten as `cronFieldMatches` handling wildcard, single, range, list, step, and named DOW/month tokens. Cosmetic finding.
|
|
15
|
+
- **"Config unchanged" message** (`src/extension/team-tool-types.ts`, `dispatch/manage.ts`): the config action always said "Updated" even when the skip-write guard left the file untouched; now shows "Config unchanged (no effective changes)." when no write occurred. Cosmetic finding.
|
|
16
|
+
|
|
17
|
+
### Verified
|
|
18
|
+
- `npm run test:critical`: 101/101 pass (default, `PI_CREW_BROKER=0`, `PI_CREW_BROKER=1`).
|
|
19
|
+
- `npx tsc --noEmit` exit 0; biome lint + format clean.
|
|
20
|
+
- Bundle md5 `a32223b037f35d8605fa3013e1d8a095` (~2857 KB).
|
|
21
|
+
- Full 9-tier `real-test-pi-crew` re-run (2026-08-10): all tiers green, all 6 fixes live-verified. Reports under `docs/real-test/reports/real-test-2026-08-10-full-9-tier*.md`.
|
|
22
|
+
|
|
5
23
|
## [0.9.65] — team-tool schema empty-string guard (budgetTotal) + effectiveness empty-result guard + skill drift fix (2026-08-10)
|
|
6
24
|
|
|
7
25
|
### Fixes
|
package/dist/index.mjs
CHANGED
|
@@ -10934,11 +10934,15 @@ function updateConfig(patch, options = {}) {
|
|
|
10934
10934
|
for (const unset of options.unsetPaths) unsetPath(raw, unset);
|
|
10935
10935
|
merged = parseConfig(raw);
|
|
10936
10936
|
}
|
|
10937
|
+
const normalizedCurrent = parseConfig(current);
|
|
10938
|
+
if (JSON.stringify(merged) === JSON.stringify(normalizedCurrent)) {
|
|
10939
|
+
return { path: filePath, config: merged, written: false };
|
|
10940
|
+
}
|
|
10937
10941
|
fs5.mkdirSync(path5.dirname(filePath), { recursive: true });
|
|
10938
10942
|
atomicWriteFile(filePath, `${JSON.stringify(merged, null, 2)}
|
|
10939
10943
|
`);
|
|
10940
10944
|
invalidateConfigCache();
|
|
10941
|
-
return { path: filePath, config: merged };
|
|
10945
|
+
return { path: filePath, config: merged, written: true };
|
|
10942
10946
|
});
|
|
10943
10947
|
}
|
|
10944
10948
|
function updateAutonomousConfig(patch) {
|
|
@@ -10953,11 +10957,15 @@ function updateAutonomousConfig(patch) {
|
|
|
10953
10957
|
throw new Error(`Could not update pi-crew config: ${message}`);
|
|
10954
10958
|
}
|
|
10955
10959
|
const currentAutonomous = current.autonomous && typeof current.autonomous === "object" && !Array.isArray(current.autonomous) ? current.autonomous : {};
|
|
10956
|
-
|
|
10960
|
+
const next = { ...current, autonomous: { ...currentAutonomous, ...patch } };
|
|
10961
|
+
if (JSON.stringify(next) === JSON.stringify(current)) {
|
|
10962
|
+
return { path: filePath, config: parseConfig(current), written: false };
|
|
10963
|
+
}
|
|
10964
|
+
current.autonomous = next.autonomous;
|
|
10957
10965
|
atomicWriteFile(filePath, `${JSON.stringify(current, null, 2)}
|
|
10958
10966
|
`);
|
|
10959
10967
|
invalidateConfigCache();
|
|
10960
|
-
return { path: filePath, config: parseConfig(current) };
|
|
10968
|
+
return { path: filePath, config: parseConfig(current), written: true };
|
|
10961
10969
|
});
|
|
10962
10970
|
}
|
|
10963
10971
|
var CONFIG_CACHE_TTL_MS, configCache, configCacheTtlMsOverride, KNOWN_TOP_LEVEL_KEYS, LIMIT_CEILINGS, DANGEROUS_OBJECT_KEYS;
|
|
@@ -35647,23 +35655,46 @@ function parseIntervalMs(s) {
|
|
|
35647
35655
|
}
|
|
35648
35656
|
return ms;
|
|
35649
35657
|
}
|
|
35658
|
+
function cronFieldMatches(value, field, min, max, names) {
|
|
35659
|
+
let normalized = field.trim().toUpperCase();
|
|
35660
|
+
if (names) {
|
|
35661
|
+
for (const name of Object.keys(names).sort((a, b) => b.length - a.length)) {
|
|
35662
|
+
normalized = normalized.split(name).join(String(names[name]));
|
|
35663
|
+
}
|
|
35664
|
+
}
|
|
35665
|
+
if (min === 0 && max === 6) normalized = normalized.replace(/\b7\b/g, "0");
|
|
35666
|
+
const matched = /* @__PURE__ */ new Set();
|
|
35667
|
+
for (const rawPart of normalized.split(",")) {
|
|
35668
|
+
const part = rawPart.trim();
|
|
35669
|
+
if (part === "") return false;
|
|
35670
|
+
const stepMatch = part.match(/^(.*)\/(\d+)$/);
|
|
35671
|
+
const step = stepMatch ? Number.parseInt(stepMatch[2], 10) : 1;
|
|
35672
|
+
if (!Number.isFinite(step) || step < 1) return false;
|
|
35673
|
+
const rangeStr = stepMatch ? stepMatch[1] : part;
|
|
35674
|
+
let lo;
|
|
35675
|
+
let hi;
|
|
35676
|
+
if (rangeStr === "*") {
|
|
35677
|
+
lo = min;
|
|
35678
|
+
hi = max;
|
|
35679
|
+
} else if (/^\d+$/.test(rangeStr)) {
|
|
35680
|
+
lo = Number.parseInt(rangeStr, 10);
|
|
35681
|
+
hi = stepMatch ? max : lo;
|
|
35682
|
+
} else {
|
|
35683
|
+
const rm = rangeStr.match(/^(\d+)-(\d+)$/);
|
|
35684
|
+
if (!rm) return false;
|
|
35685
|
+
lo = Number.parseInt(rm[1], 10);
|
|
35686
|
+
hi = Number.parseInt(rm[2], 10);
|
|
35687
|
+
}
|
|
35688
|
+
for (let v = lo; v <= hi; v += step) {
|
|
35689
|
+
if (v >= min && v <= max) matched.add(v);
|
|
35690
|
+
}
|
|
35691
|
+
}
|
|
35692
|
+
return matched.has(value);
|
|
35693
|
+
}
|
|
35650
35694
|
function nextCronDate(spec, from) {
|
|
35651
35695
|
const parts = spec.split(/\s+/);
|
|
35652
35696
|
if (parts.length < 5) return { error: "Invalid cron expression" };
|
|
35653
35697
|
const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
|
|
35654
|
-
function matchField(value, str, min, max) {
|
|
35655
|
-
if (str === "*") return true;
|
|
35656
|
-
const n = parseInt(str, 10);
|
|
35657
|
-
if (!Number.isNaN(n) && n >= min && n <= max && n === value) return true;
|
|
35658
|
-
if (/^\d+-\d+$/.test(str)) {
|
|
35659
|
-
const [a, b] = str.split("-").map(Number);
|
|
35660
|
-
return value >= a && value <= b;
|
|
35661
|
-
}
|
|
35662
|
-
if (str.includes(",")) {
|
|
35663
|
-
return str.split(",").some((part) => matchField(value, part.trim(), min, max));
|
|
35664
|
-
}
|
|
35665
|
-
return false;
|
|
35666
|
-
}
|
|
35667
35698
|
let cursor = new Date(from.getTime());
|
|
35668
35699
|
cursor.setSeconds(0, 0);
|
|
35669
35700
|
cursor = new Date(cursor.getTime() + 6e4);
|
|
@@ -35674,7 +35705,7 @@ function nextCronDate(spec, from) {
|
|
|
35674
35705
|
const dom = cursor.getUTCDate();
|
|
35675
35706
|
const month = cursor.getUTCMonth() + 1;
|
|
35676
35707
|
const dow = cursor.getUTCDay();
|
|
35677
|
-
if (
|
|
35708
|
+
if (cronFieldMatches(min, minStr, 0, 59) && cronFieldMatches(hour, hourStr, 0, 23) && cronFieldMatches(dom, domStr, 1, 31) && cronFieldMatches(month, monthStr, 1, 12, CRON_MONTH_NAMES) && cronFieldMatches(dow, dowStr, 0, 6, CRON_DOW_NAMES)) {
|
|
35678
35709
|
return cursor;
|
|
35679
35710
|
}
|
|
35680
35711
|
cursor = new Date(cursor.getTime() + 6e4);
|
|
@@ -35752,7 +35783,7 @@ function humanizeSchedule(spec) {
|
|
|
35752
35783
|
}
|
|
35753
35784
|
return "unknown schedule";
|
|
35754
35785
|
}
|
|
35755
|
-
var CrewScheduler;
|
|
35786
|
+
var CrewScheduler, CRON_DOW_NAMES, CRON_MONTH_NAMES;
|
|
35756
35787
|
var init_scheduler = __esm({
|
|
35757
35788
|
"src/runtime/scheduling/scheduler.ts"() {
|
|
35758
35789
|
"use strict";
|
|
@@ -35907,6 +35938,21 @@ var init_scheduler = __esm({
|
|
|
35907
35938
|
throw new Error(`Invalid schedule "${s}". Use "5m", "+10m", ISO timestamp, or cron expression.`);
|
|
35908
35939
|
}
|
|
35909
35940
|
};
|
|
35941
|
+
CRON_DOW_NAMES = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
|
|
35942
|
+
CRON_MONTH_NAMES = {
|
|
35943
|
+
JAN: 1,
|
|
35944
|
+
FEB: 2,
|
|
35945
|
+
MAR: 3,
|
|
35946
|
+
APR: 4,
|
|
35947
|
+
MAY: 5,
|
|
35948
|
+
JUN: 6,
|
|
35949
|
+
JUL: 7,
|
|
35950
|
+
AUG: 8,
|
|
35951
|
+
SEP: 9,
|
|
35952
|
+
OCT: 10,
|
|
35953
|
+
NOV: 11,
|
|
35954
|
+
DEC: 12
|
|
35955
|
+
};
|
|
35910
35956
|
}
|
|
35911
35957
|
});
|
|
35912
35958
|
|
|
@@ -36532,8 +36578,13 @@ var cancel_exports = {};
|
|
|
36532
36578
|
__export(cancel_exports, {
|
|
36533
36579
|
abortOwned: () => abortOwned,
|
|
36534
36580
|
handleCancel: () => handleCancel,
|
|
36535
|
-
handleRetry: () => handleRetry
|
|
36581
|
+
handleRetry: () => handleRetry,
|
|
36582
|
+
retryShortCircuitsCompleted: () => retryShortCircuitsCompleted
|
|
36536
36583
|
});
|
|
36584
|
+
function retryShortCircuitsCompleted(runStatus, tasks, targetTaskId) {
|
|
36585
|
+
if (runStatus !== "completed") return false;
|
|
36586
|
+
return !tasks.some((task) => (targetTaskId ? task.id === targetTaskId : true) && RETRYABLE_STATUSES.has(task.status));
|
|
36587
|
+
}
|
|
36537
36588
|
function abortOwned(runId, taskIds, ctx, force) {
|
|
36538
36589
|
const runCwd = locateRunCwd(runId, ctx.cwd);
|
|
36539
36590
|
if (!runCwd) return { abortedIds: [], missingIds: taskIds ?? [], foreignIds: [] };
|
|
@@ -36606,6 +36657,13 @@ async function handleRetry(params, ctx, deps) {
|
|
|
36606
36657
|
);
|
|
36607
36658
|
}
|
|
36608
36659
|
const targetTaskId = typeof params.taskId === "string" ? params.taskId : void 0;
|
|
36660
|
+
if (retryShortCircuitsCompleted(loaded.manifest.status, loaded.tasks, targetTaskId)) {
|
|
36661
|
+
return result(
|
|
36662
|
+
`Run ${loaded.manifest.runId} is already completed; retry only applies to failed/cancelled runs.`,
|
|
36663
|
+
{ action: "retry", status: "error", runId: loaded.manifest.runId },
|
|
36664
|
+
true
|
|
36665
|
+
);
|
|
36666
|
+
}
|
|
36609
36667
|
return withRunLockSync(loaded.manifest, () => {
|
|
36610
36668
|
const retryableStatuses = /* @__PURE__ */ new Set(["failed", "cancelled"]);
|
|
36611
36669
|
const matchingTasks = loaded.tasks.filter((task) => {
|
|
@@ -36810,6 +36868,7 @@ async function handleCancel(params, ctx, deps) {
|
|
|
36810
36868
|
});
|
|
36811
36869
|
});
|
|
36812
36870
|
}
|
|
36871
|
+
var RETRYABLE_STATUSES;
|
|
36813
36872
|
var init_cancel = __esm({
|
|
36814
36873
|
"src/extension/team-tool/cancel.ts"() {
|
|
36815
36874
|
"use strict";
|
|
@@ -36829,6 +36888,7 @@ var init_cancel = __esm({
|
|
|
36829
36888
|
init_intent_policy();
|
|
36830
36889
|
init_param_error();
|
|
36831
36890
|
init_run_not_found();
|
|
36891
|
+
RETRYABLE_STATUSES = /* @__PURE__ */ new Set(["failed", "cancelled"]);
|
|
36832
36892
|
}
|
|
36833
36893
|
});
|
|
36834
36894
|
|
|
@@ -38806,7 +38866,9 @@ function handleWorktrees(params, ctx) {
|
|
|
38806
38866
|
{ action: "worktrees", status: "error" },
|
|
38807
38867
|
true
|
|
38808
38868
|
);
|
|
38809
|
-
const
|
|
38869
|
+
const runCwd = locateRunCwd(params.runId, ctx.cwd);
|
|
38870
|
+
if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
|
|
38871
|
+
const loaded = loadRunManifestById(runCwd, params.runId);
|
|
38810
38872
|
if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
|
|
38811
38873
|
const withWorktrees = loaded.tasks.filter((task) => task.worktree);
|
|
38812
38874
|
const lines = [
|
|
@@ -39331,6 +39393,7 @@ var init_lifecycle_actions = __esm({
|
|
|
39331
39393
|
init_run_export();
|
|
39332
39394
|
init_run_import();
|
|
39333
39395
|
init_run_maintenance();
|
|
39396
|
+
init_team_tool2();
|
|
39334
39397
|
init_context();
|
|
39335
39398
|
init_intent_policy();
|
|
39336
39399
|
init_param_error();
|
|
@@ -41167,10 +41230,13 @@ async function handleManageDomain(params, ctx) {
|
|
|
41167
41230
|
unsetPaths
|
|
41168
41231
|
});
|
|
41169
41232
|
return result(
|
|
41170
|
-
[
|
|
41171
|
-
"
|
|
41172
|
-
|
|
41173
|
-
|
|
41233
|
+
[
|
|
41234
|
+
saved.written ? "Updated pi-crew config." : "Config unchanged (no effective changes).",
|
|
41235
|
+
`Path: ${saved.path}`,
|
|
41236
|
+
"Effective config:",
|
|
41237
|
+
JSON.stringify(saved.config, null, 2)
|
|
41238
|
+
].join("\n"),
|
|
41239
|
+
{ action: "config", status: "ok", written: saved.written }
|
|
41174
41240
|
);
|
|
41175
41241
|
} catch (error) {
|
|
41176
41242
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -47796,7 +47862,8 @@ function handleExplain(params, cwd) {
|
|
|
47796
47862
|
if (!params.runId) {
|
|
47797
47863
|
return result3("explain requires runId", { action: "explain", status: "error" }, true);
|
|
47798
47864
|
}
|
|
47799
|
-
const
|
|
47865
|
+
const runCwd = locateRunCwd(params.runId, cwd);
|
|
47866
|
+
const loaded = runCwd ? loadRunManifestById(runCwd, params.runId) : void 0;
|
|
47800
47867
|
if (!loaded) {
|
|
47801
47868
|
return result3(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "explain", status: "error" }, true);
|
|
47802
47869
|
}
|
|
@@ -47855,6 +47922,7 @@ var init_explain = __esm({
|
|
|
47855
47922
|
"src/extension/team-tool/explain.ts"() {
|
|
47856
47923
|
"use strict";
|
|
47857
47924
|
init_state_store();
|
|
47925
|
+
init_team_tool2();
|
|
47858
47926
|
init_run_not_found();
|
|
47859
47927
|
}
|
|
47860
47928
|
});
|
|
@@ -54561,16 +54629,24 @@ function validateWorkerOutput(role, output) {
|
|
|
54561
54629
|
issues
|
|
54562
54630
|
};
|
|
54563
54631
|
}
|
|
54564
|
-
var ROLE_PATTERN_DEFS, makeUrlRe;
|
|
54632
|
+
var MARKDOWN_STRUCTURED, STRICT_ROLE_PATTERNS, ROLE_PATTERN_DEFS, makeUrlRe;
|
|
54565
54633
|
var init_output_validator = __esm({
|
|
54566
54634
|
"src/runtime/output/output-validator.ts"() {
|
|
54567
54635
|
"use strict";
|
|
54636
|
+
MARKDOWN_STRUCTURED = /^(?:#{1,6}\s|\*\*|[-*]\s|\d+\.\s)/m;
|
|
54637
|
+
STRICT_ROLE_PATTERNS = {
|
|
54638
|
+
explorer: /^(\S+:\d+|Defs:|Refs:|Callers:|Tests:|Sites:|No match\.|totals:)/m,
|
|
54639
|
+
executor: /^(\S+:\d+(-\d+)? — .{1,80}\.|verified:|too-big\.|needs-confirm\.|ambiguous\.|regressed\.)/m,
|
|
54640
|
+
reviewer: new RegExp("^([^:\\s]+:\\d+:\\s+\\p{Emoji_Presentation}|No issues\\.|totals:)", "mu"),
|
|
54641
|
+
"security-reviewer": new RegExp("^([^:\\s]+:\\d+:\\s+\\p{Emoji_Presentation}|No issues\\.|totals:)", "mu"),
|
|
54642
|
+
verifier: /^(PASS:|FAIL:)/m
|
|
54643
|
+
};
|
|
54568
54644
|
ROLE_PATTERN_DEFS = {
|
|
54569
|
-
explorer: () =>
|
|
54570
|
-
executor: () =>
|
|
54571
|
-
reviewer: () => new RegExp(
|
|
54572
|
-
"security-reviewer": () => new RegExp(
|
|
54573
|
-
verifier: () =>
|
|
54645
|
+
explorer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.explorer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
|
|
54646
|
+
executor: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.executor.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
|
|
54647
|
+
reviewer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.reviewer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
|
|
54648
|
+
"security-reviewer": () => new RegExp(`(?:${STRICT_ROLE_PATTERNS["security-reviewer"].source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
|
|
54649
|
+
verifier: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.verifier.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m")
|
|
54574
54650
|
};
|
|
54575
54651
|
makeUrlRe = () => /\bhttps?:\/\/[^\s<>)\]"',;]+/gi;
|
|
54576
54652
|
}
|
package/package.json
CHANGED
package/src/config/config.ts
CHANGED
|
@@ -1249,12 +1249,20 @@ export function updateConfig(patch: PiTeamsConfig, options: UpdateConfigOptions
|
|
|
1249
1249
|
for (const unset of options.unsetPaths) unsetPath(raw, unset);
|
|
1250
1250
|
merged = parseConfig(raw);
|
|
1251
1251
|
}
|
|
1252
|
+
// Skip-if-unchanged: an empty/identical patch must not rewrite the file
|
|
1253
|
+
// (e.g. `team action='config'` with an empty patch — read-only path).
|
|
1254
|
+
// Both sides are parseConfig-normalized, so JSON.stringify key order is
|
|
1255
|
+
// deterministic (same construction path); no key sorting needed.
|
|
1256
|
+
const normalizedCurrent = parseConfig(current);
|
|
1257
|
+
if (JSON.stringify(merged) === JSON.stringify(normalizedCurrent)) {
|
|
1258
|
+
return { path: filePath, config: merged, written: false }; // unchanged — skip write
|
|
1259
|
+
}
|
|
1252
1260
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1253
1261
|
atomicWriteFile(filePath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
1254
1262
|
// (F16) Invalidate the loadConfig cache after a write — the next
|
|
1255
1263
|
// caller must see the new value, not a 0-2s stale snapshot.
|
|
1256
1264
|
invalidateConfigCache();
|
|
1257
|
-
return { path: filePath, config: merged };
|
|
1265
|
+
return { path: filePath, config: merged, written: true };
|
|
1258
1266
|
});
|
|
1259
1267
|
}
|
|
1260
1268
|
|
|
@@ -1273,10 +1281,18 @@ export function updateAutonomousConfig(patch: PiTeamsAutonomousConfig): SavedPiT
|
|
|
1273
1281
|
current.autonomous && typeof current.autonomous === "object" && !Array.isArray(current.autonomous)
|
|
1274
1282
|
? (current.autonomous as Record<string, unknown>)
|
|
1275
1283
|
: {};
|
|
1276
|
-
|
|
1284
|
+
// Skip-if-unchanged (raw shape): a no-op autonomous patch must not
|
|
1285
|
+
// rewrite the file. NOTE: compare the RAW on-disk record, NOT the
|
|
1286
|
+
// parseConfig-normalized shape — normalizing would add default keys and
|
|
1287
|
+
// false-positive the equality check.
|
|
1288
|
+
const next = { ...current, autonomous: { ...currentAutonomous, ...patch } };
|
|
1289
|
+
if (JSON.stringify(next) === JSON.stringify(current)) {
|
|
1290
|
+
return { path: filePath, config: parseConfig(current), written: false }; // unchanged — skip write
|
|
1291
|
+
}
|
|
1292
|
+
current.autonomous = next.autonomous;
|
|
1277
1293
|
atomicWriteFile(filePath, `${JSON.stringify(current, null, 2)}\n`);
|
|
1278
1294
|
// (F16) Invalidate the loadConfig cache after a write — see updateConfig.
|
|
1279
1295
|
invalidateConfigCache();
|
|
1280
|
-
return { path: filePath, config: parseConfig(current) };
|
|
1296
|
+
return { path: filePath, config: parseConfig(current), written: true };
|
|
1281
1297
|
});
|
|
1282
1298
|
}
|
package/src/config/types.ts
CHANGED
|
@@ -312,6 +312,8 @@ export interface ConfigValidationResult {
|
|
|
312
312
|
export interface SavedPiTeamsConfig {
|
|
313
313
|
config: PiTeamsConfig;
|
|
314
314
|
path: string;
|
|
315
|
+
/** Whether the file was actually rewritten. `false` when a no-op patch hit the skip-write guard. */
|
|
316
|
+
written: boolean;
|
|
315
317
|
}
|
|
316
318
|
|
|
317
319
|
export interface UpdateConfigOptions {
|
|
@@ -21,6 +21,28 @@ import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
|
|
|
21
21
|
import { paramRequired } from "./param-error.ts";
|
|
22
22
|
import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
|
|
23
23
|
|
|
24
|
+
/** Retryable terminal statuses (a task in one of these can be re-queued). */
|
|
25
|
+
const RETRYABLE_STATUSES: ReadonlySet<string> = new Set(["failed", "cancelled"]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pure pre-lock decision for `action='retry'`: a run whose manifest status is
|
|
29
|
+
* "completed" (terminal success) and has no retryable tasks has nothing to
|
|
30
|
+
* retry. Returns true so the caller short-circuits with a clear message
|
|
31
|
+
* BEFORE acquiring the run lock — avoiding a misleading
|
|
32
|
+
* "run.lock is locked by another operation" error from a stale lock file left
|
|
33
|
+
* behind by a completed async run (finding #4, real-test-2026-08-10-full-9-tier).
|
|
34
|
+
*
|
|
35
|
+
* Exported for unit testing (handleRetry itself needs filesystem state).
|
|
36
|
+
*/
|
|
37
|
+
export function retryShortCircuitsCompleted(
|
|
38
|
+
runStatus: string,
|
|
39
|
+
tasks: ReadonlyArray<{ id: string; status: string }>,
|
|
40
|
+
targetTaskId?: string,
|
|
41
|
+
): boolean {
|
|
42
|
+
if (runStatus !== "completed") return false;
|
|
43
|
+
return !tasks.some((task) => (targetTaskId ? task.id === targetTaskId : true) && RETRYABLE_STATUSES.has(task.status));
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
export interface AbortOwnedResult {
|
|
25
47
|
abortedIds: string[];
|
|
26
48
|
missingIds: string[];
|
|
@@ -126,6 +148,18 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
|
|
|
126
148
|
|
|
127
149
|
const targetTaskId = typeof params.taskId === "string" ? params.taskId : undefined;
|
|
128
150
|
|
|
151
|
+
// Pre-lock terminal-status check: a completed run has nothing to retry.
|
|
152
|
+
// Short-circuit BEFORE acquiring the run lock so a stale lock file left by a
|
|
153
|
+
// completed async run does not surface a misleading "run.lock is locked by
|
|
154
|
+
// another operation" error (finding #4 in real-test-2026-08-10-full-9-tier).
|
|
155
|
+
if (retryShortCircuitsCompleted(loaded.manifest.status, loaded.tasks, targetTaskId)) {
|
|
156
|
+
return result(
|
|
157
|
+
`Run ${loaded.manifest.runId} is already completed; retry only applies to failed/cancelled runs.`,
|
|
158
|
+
{ action: "retry", status: "error", runId: loaded.manifest.runId },
|
|
159
|
+
true,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
129
163
|
return withRunLockSync(loaded.manifest, () => {
|
|
130
164
|
const retryableStatuses: ReadonlySet<string> = new Set(["failed", "cancelled"]);
|
|
131
165
|
|
|
@@ -130,10 +130,13 @@ export async function handleManageDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
130
130
|
unsetPaths,
|
|
131
131
|
});
|
|
132
132
|
return result(
|
|
133
|
-
[
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
[
|
|
134
|
+
saved.written ? "Updated pi-crew config." : "Config unchanged (no effective changes).",
|
|
135
|
+
`Path: ${saved.path}`,
|
|
136
|
+
"Effective config:",
|
|
137
|
+
JSON.stringify(saved.config, null, 2),
|
|
138
|
+
].join("\n"),
|
|
139
|
+
{ action: "config", status: "ok", written: saved.written },
|
|
137
140
|
);
|
|
138
141
|
} catch (error) {
|
|
139
142
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
4
4
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
5
|
+
import { locateRunCwd } from "../team-tool.ts";
|
|
5
6
|
import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -215,7 +216,8 @@ export function handleExplain(
|
|
|
215
216
|
return result("explain requires runId", { action: "explain", status: "error" }, true);
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
const
|
|
219
|
+
const runCwd = locateRunCwd(params.runId, cwd);
|
|
220
|
+
const loaded = runCwd ? loadRunManifestById(runCwd, params.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
219
221
|
if (!loaded) {
|
|
220
222
|
return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "explain", status: "error" }, true);
|
|
221
223
|
}
|
|
@@ -16,6 +16,7 @@ import { listImportedRuns } from "../import-index.ts";
|
|
|
16
16
|
import { exportRunBundle } from "../run-export.ts";
|
|
17
17
|
import { importRunBundle } from "../run-import.ts";
|
|
18
18
|
import { pruneFinishedRuns } from "../run-maintenance.ts";
|
|
19
|
+
import { locateRunCwd } from "../team-tool.ts";
|
|
19
20
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
20
21
|
import { configRecord, result, type TeamContext } from "./context.ts";
|
|
21
22
|
import { enforceDestructiveIntent, intentFromConfig } from "./intent-policy.ts";
|
|
@@ -29,7 +30,9 @@ export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
29
30
|
{ action: "worktrees", status: "error" },
|
|
30
31
|
true,
|
|
31
32
|
);
|
|
32
|
-
const
|
|
33
|
+
const runCwd = locateRunCwd(params.runId, ctx.cwd);
|
|
34
|
+
if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
|
|
35
|
+
const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
33
36
|
if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "worktrees", status: "error" }, true);
|
|
34
37
|
const withWorktrees = loaded.tasks.filter((task) => task.worktree);
|
|
35
38
|
const lines = [
|
|
@@ -10,6 +10,8 @@ export interface TeamToolDetails {
|
|
|
10
10
|
resumedIds?: string[];
|
|
11
11
|
retriedTaskIds?: string[];
|
|
12
12
|
mailboxIds?: string[];
|
|
13
|
+
/** Whether a config write actually persisted (false on no-op/skip-write). */
|
|
14
|
+
written?: boolean;
|
|
13
15
|
/** Resource scope affected by the action (e.g. cleanup: "project"). */
|
|
14
16
|
scope?: string;
|
|
15
17
|
/** Run metrics for compact display in TUI tool result rendering. */
|
|
@@ -9,13 +9,41 @@
|
|
|
9
9
|
* (headings, code blocks, URLs) after compression.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Why relax: real worker LLMs emit markdown handoffs (`## Handoff`,
|
|
14
|
+
* `### Summary`, `## Follow-ups`, `- bullet`, `**bold**`) instead of the
|
|
15
|
+
* caveman formats (`file:line — text`, `PASS:`/`FAIL:`, emoji findings)
|
|
16
|
+
* these patterns originally required. Each role pattern below ORs its
|
|
17
|
+
* strict contract with a markdown-structured alternation so structured
|
|
18
|
+
* handoffs validate while empty/garbage output still fails.
|
|
19
|
+
*
|
|
20
|
+
* What changed: added the MARKDOWN_STRUCTURED alternation to every role.
|
|
21
|
+
* What is preserved: the strict patterns still match verbatim, and the
|
|
22
|
+
* structural-preservation checks (code blocks, URLs, headings) in
|
|
23
|
+
* validateWorkerOutput are UNCHANGED.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Accepts atx headings (`## X`), bold (`**x**`), bullets (`- x` / `* x`), and numbered lists (`1. x`) */
|
|
27
|
+
const MARKDOWN_STRUCTURED = /^(?:#{1,6}\s|\*\*|[-*]\s|\d+\.\s)/m;
|
|
28
|
+
|
|
29
|
+
/** Strict per-role contract patterns (kept verbatim; `.source` is embedded in the alternations below) */
|
|
30
|
+
const STRICT_ROLE_PATTERNS: Record<string, RegExp> = {
|
|
31
|
+
explorer: /^(\S+:\d+|Defs:|Refs:|Callers:|Tests:|Sites:|No match\.|totals:)/m,
|
|
32
|
+
executor: /^(\S+:\d+(-\d+)? — .{1,80}\.|verified:|too-big\.|needs-confirm\.|ambiguous\.|regressed\.)/m,
|
|
33
|
+
reviewer: /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
|
|
34
|
+
"security-reviewer": /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
|
|
35
|
+
verifier: /^(PASS:|FAIL:)/m,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Role-specific output format patterns — constructed fresh per call to avoid /g lastIndex leak.
|
|
39
|
+
* Each factory: strict contract OR markdown-structured alternation. */
|
|
13
40
|
const ROLE_PATTERN_DEFS: Record<string, () => RegExp> = {
|
|
14
|
-
explorer: () =>
|
|
15
|
-
executor: () =>
|
|
16
|
-
reviewer: () =>
|
|
17
|
-
"security-reviewer": () =>
|
|
18
|
-
|
|
41
|
+
explorer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.explorer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
|
|
42
|
+
executor: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.executor.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
|
|
43
|
+
reviewer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.reviewer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
|
|
44
|
+
"security-reviewer": () =>
|
|
45
|
+
new RegExp(`(?:${STRICT_ROLE_PATTERNS["security-reviewer"].source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
|
|
46
|
+
verifier: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.verifier.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
|
|
19
47
|
};
|
|
20
48
|
|
|
21
49
|
/** Fresh RegExp factories for structural preservation checks (avoids /g lastIndex leak) */
|
|
@@ -229,24 +229,72 @@ function parseIntervalMs(s: string): number | undefined {
|
|
|
229
229
|
return ms;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
/** Named-token maps for cron DOW (SUN=0..SAT=6) and month (JAN=1..DEC=12). */
|
|
233
|
+
const CRON_DOW_NAMES: Record<string, number> = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
|
|
234
|
+
const CRON_MONTH_NAMES: Record<string, number> = {
|
|
235
|
+
JAN: 1,
|
|
236
|
+
FEB: 2,
|
|
237
|
+
MAR: 3,
|
|
238
|
+
APR: 4,
|
|
239
|
+
MAY: 5,
|
|
240
|
+
JUN: 6,
|
|
241
|
+
JUL: 7,
|
|
242
|
+
AUG: 8,
|
|
243
|
+
SEP: 9,
|
|
244
|
+
OCT: 10,
|
|
245
|
+
NOV: 11,
|
|
246
|
+
DEC: 12,
|
|
247
|
+
};
|
|
236
248
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Match a single cron field value against a cron field expression.
|
|
251
|
+
* Supports the standard cron grammar: wildcard, single N, range a-b, list a,b,c,
|
|
252
|
+
* step syntax (wildcard-step, range-step, from-step), and named tokens
|
|
253
|
+
* (MON, JAN) when a `names` map is passed. Fixes the prior matcher that
|
|
254
|
+
* rejected step values and named DOW (parseInt returned NaN).
|
|
255
|
+
*/
|
|
256
|
+
function cronFieldMatches(value: number, field: string, min: number, max: number, names?: Record<string, number>): boolean {
|
|
257
|
+
let normalized = field.trim().toUpperCase();
|
|
258
|
+
if (names) {
|
|
259
|
+
for (const name of Object.keys(names).sort((a, b) => b.length - a.length)) {
|
|
260
|
+
normalized = normalized.split(name).join(String(names[name]));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Cron permits 7 for Sunday in the DOW field — normalize to 0.
|
|
264
|
+
if (min === 0 && max === 6) normalized = normalized.replace(/\b7\b/g, "0");
|
|
265
|
+
const matched = new Set<number>();
|
|
266
|
+
for (const rawPart of normalized.split(",")) {
|
|
267
|
+
const part = rawPart.trim();
|
|
268
|
+
if (part === "") return false;
|
|
269
|
+
const stepMatch = part.match(/^(.*)\/(\d+)$/);
|
|
270
|
+
const step = stepMatch ? Number.parseInt(stepMatch[2], 10) : 1;
|
|
271
|
+
if (!Number.isFinite(step) || step < 1) return false;
|
|
272
|
+
const rangeStr = stepMatch ? stepMatch[1] : part;
|
|
273
|
+
let lo: number;
|
|
274
|
+
let hi: number;
|
|
275
|
+
if (rangeStr === "*") {
|
|
276
|
+
lo = min;
|
|
277
|
+
hi = max;
|
|
278
|
+
} else if (/^\d+$/.test(rangeStr)) {
|
|
279
|
+
lo = Number.parseInt(rangeStr, 10);
|
|
280
|
+
hi = stepMatch ? max : lo; // bare `N` = single value; `N/S` = N..max step S
|
|
281
|
+
} else {
|
|
282
|
+
const rm = rangeStr.match(/^(\d+)-(\d+)$/);
|
|
283
|
+
if (!rm) return false;
|
|
284
|
+
lo = Number.parseInt(rm[1], 10);
|
|
285
|
+
hi = Number.parseInt(rm[2], 10);
|
|
244
286
|
}
|
|
245
|
-
|
|
246
|
-
|
|
287
|
+
for (let v = lo; v <= hi; v += step) {
|
|
288
|
+
if (v >= min && v <= max) matched.add(v);
|
|
247
289
|
}
|
|
248
|
-
return false;
|
|
249
290
|
}
|
|
291
|
+
return matched.has(value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function nextCronDate(spec: string, from: Date): Date | { error: string } | null {
|
|
295
|
+
const parts = spec.split(/\s+/);
|
|
296
|
+
if (parts.length < 5) return { error: "Invalid cron expression" };
|
|
297
|
+
const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
|
|
250
298
|
|
|
251
299
|
let cursor = new Date(from.getTime());
|
|
252
300
|
cursor.setSeconds(0, 0);
|
|
@@ -261,11 +309,11 @@ function nextCronDate(spec: string, from: Date): Date | { error: string } | null
|
|
|
261
309
|
const dow = cursor.getUTCDay();
|
|
262
310
|
|
|
263
311
|
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
312
|
+
cronFieldMatches(min, minStr, 0, 59) &&
|
|
313
|
+
cronFieldMatches(hour, hourStr, 0, 23) &&
|
|
314
|
+
cronFieldMatches(dom, domStr, 1, 31) &&
|
|
315
|
+
cronFieldMatches(month, monthStr, 1, 12, CRON_MONTH_NAMES) &&
|
|
316
|
+
cronFieldMatches(dow, dowStr, 0, 6, CRON_DOW_NAMES)
|
|
269
317
|
) {
|
|
270
318
|
return cursor;
|
|
271
319
|
}
|