claude-code-session-manager 0.85.0 → 0.87.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/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
- package/dist/assets/{DataModel-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-DhvuQL4C.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +15 -14
- package/plugins/CLAUDE.md +61 -0
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
- package/scripts/scheduler-mcp-server.cjs +7 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
- package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
- package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
- package/src/main/__tests__/machineProfile.test.cjs +19 -1
- package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
- package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
- package/src/main/__tests__/prdLocations.test.cjs +100 -2
- package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
- package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
- package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
- package/src/main/__tests__/queueHistory.test.cjs +63 -0
- package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
- package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
- package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +154 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/__tests__/telemetryClient.test.cjs +75 -5
- package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +76 -3
- package/src/main/historyAggregator.cjs +5 -0
- package/src/main/index.cjs +95 -44
- package/src/main/ipcSchemas.cjs +47 -0
- package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
- package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
- package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
- package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
- package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
- package/src/main/lib/agentModelResolve.cjs +65 -27
- package/src/main/lib/bootSelfHeal.cjs +88 -0
- package/src/main/lib/delegationReadiness.cjs +290 -225
- package/src/main/lib/effectiveModelInfo.cjs +333 -0
- package/src/main/lib/ephemeralCwd.cjs +1 -1
- package/src/main/lib/epicMint.cjs +3 -3
- package/src/main/lib/gitWorktree.cjs +42 -12
- package/src/main/lib/guardShims.cjs +156 -0
- package/src/main/lib/jobDirtFilter.cjs +7 -2
- package/src/main/lib/launchFailure.cjs +2 -1
- package/src/main/lib/loadGate.cjs +23 -1
- package/src/main/lib/machineProfile.cjs +15 -0
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/needsReviewLedger.cjs +205 -0
- package/src/main/lib/opsErrorLog.cjs +1 -1
- package/src/main/lib/opsOwnership.cjs +1 -1
- package/src/main/lib/prdCreate.cjs +56 -1
- package/src/main/lib/prdDisposition.cjs +199 -0
- package/src/main/lib/prdFrontmatter.cjs +8 -2
- package/src/main/lib/prdLocations.cjs +167 -45
- package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
- package/src/main/lib/projectPageSummarySchema.cjs +1 -1
- package/src/main/lib/projectRootResolve.cjs +1 -1
- package/src/main/lib/queueHistory.cjs +19 -1
- package/src/main/lib/queueStore.cjs +6 -1
- package/src/main/lib/reaperHelpers.cjs +181 -15
- package/src/main/lib/scheduleJobSchema.cjs +8 -0
- package/src/main/lib/scheduleJobTransitions.cjs +33 -0
- package/src/main/lib/schedulerBatch.cjs +12 -1
- package/src/main/lib/schedulerConfig.cjs +37 -0
- package/src/main/lib/telemetryBoot.cjs +11 -8
- package/src/main/lib/telemetryClient.cjs +44 -2
- package/src/main/lib/telemetrySettings.cjs +20 -3
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/pty.cjs +9 -0
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1869 -193
- package/src/main/templates/PRD_AUTHORING.md +50 -0
- package/src/main/templates/project-pages-catalog.json +1 -1
- package/src/main/usage.cjs +21 -3
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +10 -0
- package/web/README.md +41 -0
- package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
- package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
- package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
- package/dist/assets/AgentLibrary-Bkv-HcP1.js +0 -3
- package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
- package/dist/assets/Permissions-DKoNVgzj.js +0 -3
- package/dist/assets/Scheduler-CbES7MC8.js +0 -14
- package/dist/assets/Settings-BX3FElXk.js +0 -3
- package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
|
@@ -32,6 +32,15 @@ const { loadGateThreshold, JOB_OVERRUN_FLOOR_MS } = require('./schedulerConfig.c
|
|
|
32
32
|
// every tick would bury the signal in its own noise.
|
|
33
33
|
const AUDIT_INTERVAL_MS = 10 * 60_000;
|
|
34
34
|
|
|
35
|
+
// Default hysteresis release window for the gated stretch (PRD: boundary-
|
|
36
|
+
// hovering load). A box sitting right at the threshold oscillates above/below
|
|
37
|
+
// it tick to tick; clearing `gatedSince` on the first sub-threshold sample
|
|
38
|
+
// reset the stretch counter every time, so `escalate` (and the warn-log with
|
|
39
|
+
// topCpuConsumers()) never fired even after 80+ minutes of effectively
|
|
40
|
+
// continuous gating. The stretch now only clears once load has stayed below
|
|
41
|
+
// threshold continuously for this long.
|
|
42
|
+
const RELEASE_WINDOW_MS = 2 * 60_000;
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* isLoadGated(loadavg1, cores, threshold) → boolean
|
|
37
46
|
*
|
|
@@ -82,9 +91,11 @@ function createLoadGate({
|
|
|
82
91
|
threshold = loadGateThreshold,
|
|
83
92
|
auditIntervalMs = AUDIT_INTERVAL_MS,
|
|
84
93
|
escalateAfterMs = JOB_OVERRUN_FLOOR_MS,
|
|
94
|
+
releaseWindowMs = RELEASE_WINDOW_MS,
|
|
85
95
|
} = {}) {
|
|
86
96
|
let lastAuditAt = null; // null = never audited; the first gated tick always audits
|
|
87
97
|
let gatedSince = null;
|
|
98
|
+
let belowSince = null; // start of the current continuous sub-threshold run, while a stretch is open
|
|
88
99
|
let last = null;
|
|
89
100
|
|
|
90
101
|
function evaluate({ bypass = false } = {}) {
|
|
@@ -95,10 +106,21 @@ function createLoadGate({
|
|
|
95
106
|
const ratio = c > 0 && Number.isFinite(l1) ? l1 / c : 0;
|
|
96
107
|
const wouldGate = isLoadGated(l1, c, th);
|
|
97
108
|
|
|
109
|
+
// Hysteresis: the STRETCH (gatedSince) only clears after load has stayed
|
|
110
|
+
// below threshold continuously for releaseWindowMs. This never affects
|
|
111
|
+
// `gated` itself below — a sub-threshold tick still returns gated:false
|
|
112
|
+
// immediately; only the bookkeeping/escalation lags behind.
|
|
98
113
|
if (wouldGate) {
|
|
99
114
|
if (gatedSince === null) gatedSince = t;
|
|
115
|
+
belowSince = null;
|
|
116
|
+
} else if (gatedSince !== null) {
|
|
117
|
+
if (belowSince === null) belowSince = t;
|
|
118
|
+
if (t - belowSince >= releaseWindowMs) {
|
|
119
|
+
gatedSince = null;
|
|
120
|
+
belowSince = null;
|
|
121
|
+
}
|
|
100
122
|
} else {
|
|
101
|
-
|
|
123
|
+
belowSince = null;
|
|
102
124
|
}
|
|
103
125
|
const gated = wouldGate && !bypass;
|
|
104
126
|
const bypassed = wouldGate && bypass;
|
|
@@ -51,6 +51,20 @@ function resolveInstallChannel({ appPath = null, devFlag = !!process.env.SM_DEV
|
|
|
51
51
|
return 'unknown';
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Resolves the wire-level `env` discriminator ('prod' | 'dev' | 'test') from
|
|
56
|
+
* the real signals the caller already has — never inferred downstream from
|
|
57
|
+
* an appVersion string, which is how 1,526 pre-release/test `epic.create`
|
|
58
|
+
* records ended up misread as production usage (see telemetry.md). `test`
|
|
59
|
+
* takes priority: a vitest run that happens to report a `dev` installChannel
|
|
60
|
+
* (SM_DEV set in the test env) is still test traffic, not dev usage.
|
|
61
|
+
*/
|
|
62
|
+
function resolveEnv({ isTestRunner = false, installChannel } = {}) {
|
|
63
|
+
if (isTestRunner) return 'test';
|
|
64
|
+
if (installChannel === 'dev') return 'dev';
|
|
65
|
+
return 'prod';
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
/** sha256 of the concatenated stable spec fields, truncated to 12 hex chars. */
|
|
55
69
|
function computeMachineDigest(specs) {
|
|
56
70
|
const material = [
|
|
@@ -141,4 +155,5 @@ module.exports = {
|
|
|
141
155
|
buildMachineProfile,
|
|
142
156
|
computeMachineDigest,
|
|
143
157
|
resolveInstallChannel,
|
|
158
|
+
resolveEnv,
|
|
144
159
|
};
|
|
@@ -75,7 +75,10 @@ const MCP_TOOL_CATALOG = [
|
|
|
75
75
|
+ '`tag` and `agentType` are two DIFFERENT, independent fields — `tag` is WHAT KIND '
|
|
76
76
|
+ 'of work this PRD is (feature/bug/build/...); `agentType` is WHO executes it (a '
|
|
77
77
|
+ 'persona name, e.g. "dev-lead"). `agentType` defaults to "dev-lead" when omitted, '
|
|
78
|
-
+ 'and is rejected at write time if it does not name a real persona file.'
|
|
78
|
+
+ 'and is rejected at write time if it does not name a real persona file. '
|
|
79
|
+
+ 'If this PRD has no dependsOn of its own AND the target Epic already has incomplete PRDs, '
|
|
80
|
+
+ '`disposition` ("append" or "new-head") is REQUIRED — the write is refused without it (a '
|
|
81
|
+
+ 'headless job caller instead gets a logged "append" default, since no human is present to ask).',
|
|
79
82
|
whenToUse: 'Use whenever new work should be queued into an already-approved Epic — this is the /develop path.',
|
|
80
83
|
whenNotToUse: 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
|
|
81
84
|
+ '(a) this tool call is PRESENT in your tool list but ERRORS as app-not-running / admin '
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* needsReviewLedger.cjs — pure helpers that make `needs_review` episodes
|
|
5
|
+
* durable and queryable in history.jsonl.
|
|
6
|
+
*
|
|
7
|
+
* Before this module, `queueHistory.cjs` archived a job to history.jsonl
|
|
8
|
+
* only on a TERMINAL status (completed/failed/skipped) — a needs_review
|
|
9
|
+
* park never left queue.json's hot jobs[] (it isn't terminal; see
|
|
10
|
+
* queueHistory.cjs's partitionJobs), so it never reached durable history
|
|
11
|
+
* either. Profiling history.jsonl for "how much needs_review is really
|
|
12
|
+
* happening" therefore always returned zero, however loud the problem
|
|
13
|
+
* looked in the UI.
|
|
14
|
+
*
|
|
15
|
+
* Two line shapes are appended to the SAME history.jsonl a terminal row
|
|
16
|
+
* lands in, distinguished by `kind` so existing terminal-only readers can
|
|
17
|
+
* filter them out on sight (see scheduleJobTransitions.cjs, the actual
|
|
18
|
+
* write choke point, and its callers' audit in the owning PRD):
|
|
19
|
+
* - kind: 'needs_review_entry' — one per transition INTO needs_review
|
|
20
|
+
* - kind: 'needs_review_resolution' — one per transition OUT of needs_review,
|
|
21
|
+
* referencing the entry's own runId so the two can be paired
|
|
22
|
+
*
|
|
23
|
+
* Kept fs-free and pure (mirrors reaperHelpers.cjs's rationale) so the
|
|
24
|
+
* ladder-rung classification and rollup reduction are unit-testable without
|
|
25
|
+
* touching disk or importing scheduler.cjs (which requires electron).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// The 6 named ladder rungs a needs_review episode can resolve through
|
|
29
|
+
// (spawnJob's mechanical/resume/quarantine/auto-fix branches, the periodic
|
|
30
|
+
// reverifyNeedsReview heal pass, or an explicit human/API reset). Any
|
|
31
|
+
// resolution whose transitionJob `source` doesn't match a known automated
|
|
32
|
+
// rung is bucketed as 'manual-reset' — the conservative default for
|
|
33
|
+
// anything that isn't a recognized self-healing path.
|
|
34
|
+
const LADDER_RUNGS = [
|
|
35
|
+
'mechanical-recovery',
|
|
36
|
+
'resume-recovery',
|
|
37
|
+
'quarantine',
|
|
38
|
+
'auto-fix',
|
|
39
|
+
'reverify',
|
|
40
|
+
'manual-reset',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* classifyLadderRung({ source, reason }) → one of LADDER_RUNGS.
|
|
45
|
+
*
|
|
46
|
+
* Matches the exact `source` strings scheduleJobTransitions.cjs's callers in
|
|
47
|
+
* scheduler.cjs pass for each recovery-ladder rung (see that module's
|
|
48
|
+
* LEGAL_TRANSITIONS doc comment for the full call-site inventory). Pattern
|
|
49
|
+
* matching, not an exhaustive enum lookup, because 'spawnJob:dispatch' is
|
|
50
|
+
* shared between the ordinary pending->running dispatch and the
|
|
51
|
+
* needs_review->running resume-recovery dispatch — the two are only
|
|
52
|
+
* distinguishable by `reason` text.
|
|
53
|
+
*/
|
|
54
|
+
function classifyLadderRung({ source, reason } = {}) {
|
|
55
|
+
const s = typeof source === 'string' ? source : '';
|
|
56
|
+
const r = typeof reason === 'string' ? reason : '';
|
|
57
|
+
|
|
58
|
+
if (s === 'scheduler:mechanicalRecovery') return 'mechanical-recovery';
|
|
59
|
+
if (s === 'spawnJob:dispatch' && /resume-recovery/i.test(r)) return 'resume-recovery';
|
|
60
|
+
if (s === 'spawnInvestigation:start' || s === 'spawnJob:auto-promote' || s === 'reverifyNeedsReview:auto-promote') {
|
|
61
|
+
return 'auto-fix';
|
|
62
|
+
}
|
|
63
|
+
if (s === 'reverifyNeedsReview:heal' || s === 'needsReviewAutoResolve') return 'reverify';
|
|
64
|
+
// No transitionJob call site resolves needs_review with a 'quarantine'
|
|
65
|
+
// source today (performLeftoverQuarantine cleans up leftover paths without
|
|
66
|
+
// itself changing job.status — the row stays needs_review until a later
|
|
67
|
+
// reverify/auto-resolve pass actually resolves it), but the rung is kept
|
|
68
|
+
// in LADDER_RUNGS/this classifier so a future direct-resolving quarantine
|
|
69
|
+
// path is classified correctly without another migration.
|
|
70
|
+
return 'manual-reset';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* buildNeedsReviewEntryLine(job, entry) → JSONL-ready object.
|
|
75
|
+
*
|
|
76
|
+
* `entry` is the statusHistory record scheduleJobTransitions.cjs just
|
|
77
|
+
* pushed for this transition ({ from, to, reason, source, at }). The park
|
|
78
|
+
* reason prefers the structured `verifierVerdict` (a short, bucketable
|
|
79
|
+
* code) over the free-text `heldReason`/`error` fields, since those are the
|
|
80
|
+
* only reason evidence available for parks that never went through the
|
|
81
|
+
* verifier (e.g. a pidless-reap needs_review park).
|
|
82
|
+
*/
|
|
83
|
+
function buildNeedsReviewEntryLine(job, entry) {
|
|
84
|
+
return {
|
|
85
|
+
kind: 'needs_review_entry',
|
|
86
|
+
slug: job?.slug ?? null,
|
|
87
|
+
cwd: job?.cwd ?? null,
|
|
88
|
+
epicId: job?.epicId ?? null,
|
|
89
|
+
runId: job?.runId ?? null,
|
|
90
|
+
at: entry?.at ?? new Date().toISOString(),
|
|
91
|
+
reason: job?.verifierVerdict ?? job?.heldReason ?? job?.error ?? null,
|
|
92
|
+
source: entry?.source ?? null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* buildNeedsReviewResolutionLine(job, entry, episode) → JSONL-ready object.
|
|
98
|
+
*
|
|
99
|
+
* `episode` is `{ runId, enteredAt }` captured off the job at entry time
|
|
100
|
+
* (needsReviewEntryRunId/needsReviewEnteredAt) — the resolution line
|
|
101
|
+
* references the ENTRY's runId (which may differ from `job.runId` by
|
|
102
|
+
* resolution time, e.g. after a resume-recovery re-dispatch minted a fresh
|
|
103
|
+
* one), not whatever runId the job carries right now.
|
|
104
|
+
*/
|
|
105
|
+
function buildNeedsReviewResolutionLine(job, entry, episode) {
|
|
106
|
+
const enteredAtMs = episode?.enteredAt ? Date.parse(episode.enteredAt) : NaN;
|
|
107
|
+
const resolvedAtMs = entry?.at ? Date.parse(entry.at) : NaN;
|
|
108
|
+
const dwellMs = Number.isFinite(enteredAtMs) && Number.isFinite(resolvedAtMs)
|
|
109
|
+
? Math.max(0, resolvedAtMs - enteredAtMs)
|
|
110
|
+
: null;
|
|
111
|
+
return {
|
|
112
|
+
kind: 'needs_review_resolution',
|
|
113
|
+
slug: job?.slug ?? null,
|
|
114
|
+
cwd: job?.cwd ?? null,
|
|
115
|
+
epicId: job?.epicId ?? null,
|
|
116
|
+
runId: episode?.runId ?? job?.runId ?? null,
|
|
117
|
+
at: entry?.at ?? new Date().toISOString(),
|
|
118
|
+
resolvedTo: entry?.to ?? null,
|
|
119
|
+
ladderRung: classifyLadderRung({ source: entry?.source, reason: entry?.reason }),
|
|
120
|
+
source: entry?.source ?? null,
|
|
121
|
+
dwellMs,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function percentile(sortedValues, p) {
|
|
126
|
+
if (!sortedValues.length) return null;
|
|
127
|
+
const idx = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil((p / 100) * sortedValues.length) - 1));
|
|
128
|
+
return sortedValues[idx];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* reduceNeedsReviewLedger(lines) → rollup.
|
|
133
|
+
*
|
|
134
|
+
* Pure O(n log n) reduction (the log n is the dwell-time sort for the
|
|
135
|
+
* percentiles) over a flat array of already-parsed history.jsonl rows
|
|
136
|
+
* (mixing needs_review_entry/needs_review_resolution/terminal rows is fine —
|
|
137
|
+
* anything that isn't one of the two kinds this module writes is ignored).
|
|
138
|
+
*
|
|
139
|
+
* Pairs an entry with its resolution by (slug, runId) — the same pairing
|
|
140
|
+
* key buildNeedsReviewResolutionLine's `episode.runId` guarantees matches
|
|
141
|
+
* the entry line's `runId`. A slug that parks twice (two different runIds)
|
|
142
|
+
* produces two independent pairs, matching two independent recovery
|
|
143
|
+
* episodes rather than being conflated into one.
|
|
144
|
+
*
|
|
145
|
+
* Returns:
|
|
146
|
+
* - byReason: { [reason]: entryCount } (reason = entry line's `reason`, or 'unknown')
|
|
147
|
+
* - byLadderRung: { [rung]: resolutionCount } (rung = one of LADDER_RUNGS)
|
|
148
|
+
* - unresolvedCount: entries with no matching resolution line yet
|
|
149
|
+
* - dwellMsP50 / dwellMsP90: percentiles over resolved episodes' dwellMs (null if none)
|
|
150
|
+
*/
|
|
151
|
+
function reduceNeedsReviewLedger(lines) {
|
|
152
|
+
const list = Array.isArray(lines) ? lines : [];
|
|
153
|
+
const entriesByKey = new Map();
|
|
154
|
+
const resolutionsByKey = new Map();
|
|
155
|
+
|
|
156
|
+
for (const line of list) {
|
|
157
|
+
if (!line || typeof line !== 'object') continue;
|
|
158
|
+
const key = `${line.slug ?? ''}|${line.runId ?? ''}`;
|
|
159
|
+
if (line.kind === 'needs_review_entry') {
|
|
160
|
+
entriesByKey.set(key, line);
|
|
161
|
+
} else if (line.kind === 'needs_review_resolution') {
|
|
162
|
+
resolutionsByKey.set(key, line);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const byReason = {};
|
|
167
|
+
let unresolvedCount = 0;
|
|
168
|
+
for (const [key, entry] of entriesByKey) {
|
|
169
|
+
const reason = entry.reason ?? 'unknown';
|
|
170
|
+
byReason[reason] = (byReason[reason] ?? 0) + 1;
|
|
171
|
+
if (!resolutionsByKey.has(key)) unresolvedCount += 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const byLadderRung = {};
|
|
175
|
+
const dwellMsValues = [];
|
|
176
|
+
for (const [key, resolution] of resolutionsByKey) {
|
|
177
|
+
const rung = LADDER_RUNGS.includes(resolution.ladderRung) ? resolution.ladderRung : 'manual-reset';
|
|
178
|
+
byLadderRung[rung] = (byLadderRung[rung] ?? 0) + 1;
|
|
179
|
+
// Only count dwell for episodes that actually matched an entry — a
|
|
180
|
+
// resolution line with no paired entry (e.g. history truncated by
|
|
181
|
+
// retention before the entry aged out — never happens for needs_review
|
|
182
|
+
// rows today since they're excluded from partitionJobs' archivable set,
|
|
183
|
+
// but this stays defensive) has no truthful dwell to report.
|
|
184
|
+
if (entriesByKey.has(key) && typeof resolution.dwellMs === 'number' && Number.isFinite(resolution.dwellMs)) {
|
|
185
|
+
dwellMsValues.push(resolution.dwellMs);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
dwellMsValues.sort((a, b) => a - b);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
byReason,
|
|
192
|
+
byLadderRung,
|
|
193
|
+
unresolvedCount,
|
|
194
|
+
dwellMsP50: percentile(dwellMsValues, 50),
|
|
195
|
+
dwellMsP90: percentile(dwellMsValues, 90),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
LADDER_RUNGS,
|
|
201
|
+
classifyLadderRung,
|
|
202
|
+
buildNeedsReviewEntryLine,
|
|
203
|
+
buildNeedsReviewResolutionLine,
|
|
204
|
+
reduceNeedsReviewLedger,
|
|
205
|
+
};
|
|
@@ -107,7 +107,7 @@ function writeLocalLine({ cwd, scope, level, tabId, epicId, tags, message, meta
|
|
|
107
107
|
function reportToTelemetry({ cwd, scope, level, tabId, epicId, tags, message }) {
|
|
108
108
|
try {
|
|
109
109
|
const telemetryClient = require('./telemetryClient.cjs');
|
|
110
|
-
const { projectRootOf } = require('
|
|
110
|
+
const { projectRootOf } = require('./activeSessions.cjs');
|
|
111
111
|
const normalizedCwd = projectRootOf(cwd) || cwd;
|
|
112
112
|
const autoTags = [
|
|
113
113
|
`scope:${scope || 'unknown'}`,
|
|
@@ -237,7 +237,7 @@ function resolveProjectRoot(cwd, { opsInternal = 'normalize' } = {}) {
|
|
|
237
237
|
);
|
|
238
238
|
}
|
|
239
239
|
// Lazy: activeSessions → gitWorktree → (lazily) config.cjs → this module.
|
|
240
|
-
const { projectRootOf } = require('
|
|
240
|
+
const { projectRootOf } = require('./activeSessions.cjs');
|
|
241
241
|
const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
|
|
242
242
|
const root = projectRootOf(cwd);
|
|
243
243
|
if (isEphemeralCwd(root)) {
|
|
@@ -30,6 +30,7 @@ const { fixChainDepthOf, baseSlugOf } = require('./fixChainDepth.cjs');
|
|
|
30
30
|
const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentType.cjs');
|
|
31
31
|
const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
|
|
32
32
|
const { isFixPlanSlug } = require('./fixPlanSlug.cjs');
|
|
33
|
+
const { isIncomplete, resolveChainTerminals } = require('./prdDisposition.cjs');
|
|
33
34
|
|
|
34
35
|
// A caller-supplied slug that already starts with its own `NN-` (e.g.
|
|
35
36
|
// "254-perf-x") used to silently become the double-prefixed row
|
|
@@ -74,7 +75,7 @@ function deriveSlugFromTitle(title) {
|
|
|
74
75
|
function buildPrdBody(input) {
|
|
75
76
|
const {
|
|
76
77
|
title, cwd, estimateMinutes, goal, acceptanceCriteria,
|
|
77
|
-
implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine,
|
|
78
|
+
implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition,
|
|
78
79
|
} = input;
|
|
79
80
|
|
|
80
81
|
// No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
|
|
@@ -108,6 +109,11 @@ function buildPrdBody(input) {
|
|
|
108
109
|
fmLines.push(`agentType: ${agentType || DEFAULT_PRD_AGENT_TYPE}`);
|
|
109
110
|
// Explicit ordering (PRD 832): replaces the retired shared-NN convention.
|
|
110
111
|
if (dependsOn && dependsOn.length) fmLines.push(`dependsOn: [${dependsOn.join(', ')}]`);
|
|
112
|
+
// Wave-authoring decision (scheduler wave-disposition PRD) — only ever set
|
|
113
|
+
// when createPrd() actually had a disposition to record (see the call
|
|
114
|
+
// site above); a first-ever PRD in an Epic, or one with its own explicit
|
|
115
|
+
// dependsOn, has nothing to decide against and omits this key.
|
|
116
|
+
if (disposition) fmLines.push(`disposition: ${disposition}`);
|
|
111
117
|
// Opt-in exclusive-lease flag (PRD 1107): serializes this job against
|
|
112
118
|
// every other job machine-wide for its run, for a PRD whose acceptance
|
|
113
119
|
// criteria are wall-clock/timing measurements that CPU contention from
|
|
@@ -237,6 +243,55 @@ async function createPrd(input, remote) {
|
|
|
237
243
|
if (fallback) input = { ...input, sourcePromptId: fallback };
|
|
238
244
|
}
|
|
239
245
|
|
|
246
|
+
// Wave-disposition decision point (scheduler wave-disposition PRD): only
|
|
247
|
+
// matters for a PRD that would become a ROOT of its own wave — one the
|
|
248
|
+
// caller didn't already give an explicit dependsOn (that already fixes
|
|
249
|
+
// its position in some chain) — joining an Epic whose sourcePromptId this
|
|
250
|
+
// PRD shares. See prdDisposition.cjs's header for the full contract.
|
|
251
|
+
if (input.sourcePromptId && (!input.dependsOn || input.dependsOn.length === 0)) {
|
|
252
|
+
let epicRows = [];
|
|
253
|
+
try {
|
|
254
|
+
const listing = await remote.listPrds({ cwd: input.cwd, fields: 'full', limit: Number.MAX_SAFE_INTEGER });
|
|
255
|
+
epicRows = (listing?.prds ?? []).filter((p) => p.sourcePromptId === input.sourcePromptId);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
console.warn(`[prdCreate] disposition resolution skipped (listPrds failed): ${e?.message ?? e}`);
|
|
258
|
+
}
|
|
259
|
+
if (epicRows.some((p) => isIncomplete(p.status))) {
|
|
260
|
+
let disposition = input.disposition ?? null;
|
|
261
|
+
if (!disposition) {
|
|
262
|
+
// A headless PRD executor's own job has SM_SCHEDULER_JOB_SLUG set on
|
|
263
|
+
// its env (scheduler.cjs stamps every spawned job's child env with
|
|
264
|
+
// it) — no human is present to ask, so the conservative default
|
|
265
|
+
// ('append') applies and is logged as a default, never as a silent
|
|
266
|
+
// choice. Anything else (a live interactive Epic chat session, or a
|
|
267
|
+
// direct admin-route call) is refused instead of guessed — see this
|
|
268
|
+
// PRD's AC2.
|
|
269
|
+
const nonInteractive = Boolean(process.env.SM_SCHEDULER_JOB_SLUG);
|
|
270
|
+
if (!nonInteractive) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
status: 400,
|
|
274
|
+
error: `Epic ${input.sourcePromptId} already has incomplete PRDs — pass disposition: "append" ` +
|
|
275
|
+
'(extend the existing chain behind its current tail) or "new-head" (an independent root, ' +
|
|
276
|
+
'eligible to run in parallel) to record this wave\'s relationship to the existing plan.',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
disposition = 'append';
|
|
280
|
+
console.warn(`[prdCreate] disposition defaulted to "append" for a non-interactive caller (epic ${input.sourcePromptId})`);
|
|
281
|
+
appendAuditEvent('prd_disposition_defaulted', { cwd: input.cwd, epicId: input.sourcePromptId, disposition });
|
|
282
|
+
}
|
|
283
|
+
if (disposition === 'append') {
|
|
284
|
+
// Only the Epic's still-incomplete rows count as "the active plan" to
|
|
285
|
+
// append behind — a completed, undepended-upon row would otherwise
|
|
286
|
+
// also read as a terminal, silently attaching this wave to already-
|
|
287
|
+
// finished work instead of purely the current chain's live tail(s).
|
|
288
|
+
const terminals = resolveChainTerminals(epicRows.filter((p) => isIncomplete(p.status)));
|
|
289
|
+
if (terminals.length) input = { ...input, dependsOn: terminals };
|
|
290
|
+
}
|
|
291
|
+
input = { ...input, disposition };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
240
295
|
if (input.slug) {
|
|
241
296
|
const nnMatch = SLUG_HAS_NN_PREFIX_RE.exec(input.slug);
|
|
242
297
|
if (nnMatch) {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prdDisposition.cjs — the wave-authoring decision (scheduler wave-
|
|
5
|
+
* disposition PRD): when a new PRD wave joins an Epic that already has
|
|
6
|
+
* incomplete PRDs, does the wave APPEND behind the existing chain's tail, or
|
|
7
|
+
* start an independent NEW-HEAD root eligible to run in parallel? Before
|
|
8
|
+
* this module the choice didn't exist — every new root PRD silently landed
|
|
9
|
+
* as a parallel-eligible row, so waves interleaved with no recorded
|
|
10
|
+
* relationship and a human reading the backlog couldn't tell a plan from a
|
|
11
|
+
* pile.
|
|
12
|
+
*
|
|
13
|
+
* Two call sites:
|
|
14
|
+
* - prdCreate.cjs's createPrd() — AUTHORING TIME. Resolves 'append' into a
|
|
15
|
+
* concrete dependsOn via resolveChainTerminals() over the Epic's
|
|
16
|
+
* existing rows.
|
|
17
|
+
* - scheduler.cjs's `schedule:set-prd-disposition` IPC handler — CHANGING
|
|
18
|
+
* an already-written PRD's disposition from the Scheduler UI. Uses
|
|
19
|
+
* computeDispositionRewrite() to validate the rewrite is safe before
|
|
20
|
+
* handing the new dependsOn to remote.updatePrd (which itself refuses a
|
|
21
|
+
* non-pending/quarantined row — see scheduler.cjs's updatePrd).
|
|
22
|
+
*
|
|
23
|
+
* `rows` throughout is the same shape listPrdsInternal() already produces:
|
|
24
|
+
* { slug, status, dependsOn }. dependsOn entries are resolved against `rows`
|
|
25
|
+
* via depSlugResolve.cjs's resolveDepSlug — the SAME rule
|
|
26
|
+
* schedulerBatch.cjs's findBlockingDep and prdCreate.cjs's write-time FK
|
|
27
|
+
* check use, so a terminal/cycle computed here can never disagree with what
|
|
28
|
+
* the scheduler considers a row's real blockers at run time.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { resolveDepSlug } = require('./depSlugResolve.cjs');
|
|
32
|
+
|
|
33
|
+
/** A row is "incomplete" (still part of the Epic's active plan) unless it
|
|
34
|
+
* has actually finished. Treats every other status — including a brand-new
|
|
35
|
+
* row with no queue entry yet (`status: null`) — as incomplete, since none
|
|
36
|
+
* of those represent shipped work a wave could safely ignore. */
|
|
37
|
+
function isIncomplete(status) {
|
|
38
|
+
return status !== 'completed';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolve each row's dependsOn entries to actual row slugs present in
|
|
42
|
+
* `rows`, via the shared resolution rule. A dep that doesn't resolve to
|
|
43
|
+
* anything in this row set (dangling, or lives outside this Epic) is
|
|
44
|
+
* dropped — it can't affect this Epic's own terminal computation. */
|
|
45
|
+
function resolvedDependents(rows) {
|
|
46
|
+
const slugs = rows.map((r) => r.slug);
|
|
47
|
+
const dependedUpon = new Set();
|
|
48
|
+
for (const r of rows) {
|
|
49
|
+
for (const dep of r.dependsOn ?? []) {
|
|
50
|
+
for (const resolved of resolveDepSlug(dep, slugs)) dependedUpon.add(resolved);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return dependedUpon;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every row participating in a dependsOn cycle within `rows` (self-loops
|
|
57
|
+
* included) — mirrors backlogTree.ts's detectCycles (renderer-only, can't
|
|
58
|
+
* be required from this CJS main-process module; kept in sync by hand). */
|
|
59
|
+
function detectCycles(rows) {
|
|
60
|
+
const bySlug = new Map(rows.map((r) => [r.slug, r]));
|
|
61
|
+
const slugs = rows.map((r) => r.slug);
|
|
62
|
+
const color = new Map();
|
|
63
|
+
const cyclic = new Set();
|
|
64
|
+
const stack = [];
|
|
65
|
+
|
|
66
|
+
function visit(slug) {
|
|
67
|
+
color.set(slug, 1);
|
|
68
|
+
stack.push(slug);
|
|
69
|
+
const r = bySlug.get(slug);
|
|
70
|
+
const deps = new Set();
|
|
71
|
+
for (const dep of r?.dependsOn ?? []) {
|
|
72
|
+
for (const resolved of resolveDepSlug(dep, slugs)) deps.add(resolved);
|
|
73
|
+
}
|
|
74
|
+
for (const dep of deps) {
|
|
75
|
+
const c = color.get(dep);
|
|
76
|
+
if (c === 1) {
|
|
77
|
+
const idx = stack.indexOf(dep);
|
|
78
|
+
for (let i = idx; i < stack.length; i++) cyclic.add(stack[i]);
|
|
79
|
+
} else if (c === undefined) {
|
|
80
|
+
visit(dep);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
stack.pop();
|
|
84
|
+
color.set(slug, 2);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const r of rows) {
|
|
88
|
+
if (!color.has(r.slug)) visit(r.slug);
|
|
89
|
+
}
|
|
90
|
+
return cyclic;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Terminal (leaf) slugs of the dependency forest formed by `rows` — the
|
|
95
|
+
* row(s) nothing else in `rows` depends on. This is what an 'append'
|
|
96
|
+
* disposition attaches a new wave's root behind: depending on every current
|
|
97
|
+
* terminal means the new wave runs only once everything already queued (in
|
|
98
|
+
* every existing head) has finished, the conservative reading of "append".
|
|
99
|
+
* A row participating in a cycle is excluded — never used as a rewrite
|
|
100
|
+
* target, since a cycle indicates the existing graph shouldn't be extended
|
|
101
|
+
* until it's fixed by hand.
|
|
102
|
+
*/
|
|
103
|
+
function resolveChainTerminals(rows) {
|
|
104
|
+
if (!rows.length) return [];
|
|
105
|
+
const dependedUpon = resolvedDependents(rows);
|
|
106
|
+
const cyclic = detectCycles(rows);
|
|
107
|
+
return rows
|
|
108
|
+
.map((r) => r.slug)
|
|
109
|
+
.filter((slug) => !dependedUpon.has(slug) && !cyclic.has(slug));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Would setting `slug`'s dependsOn to `newDependsOn` create a cycle? Checks
|
|
114
|
+
* reachability from each (resolved) proposed dependency back to `slug`
|
|
115
|
+
* following the EXISTING graph in `rows` (never `slug`'s own current
|
|
116
|
+
* dependsOn, which is being replaced) — a direct self-reference is always a
|
|
117
|
+
* cycle, checked first without needing a graph walk.
|
|
118
|
+
*/
|
|
119
|
+
function wouldCreateCycle(rows, slug, newDependsOn) {
|
|
120
|
+
const slugs = rows.map((r) => r.slug);
|
|
121
|
+
const bySlug = new Map(rows.map((r) => [r.slug, r]));
|
|
122
|
+
const resolvedTargets = new Set();
|
|
123
|
+
for (const dep of newDependsOn) {
|
|
124
|
+
for (const resolved of resolveDepSlug(dep, slugs)) resolvedTargets.add(resolved);
|
|
125
|
+
}
|
|
126
|
+
if (resolvedTargets.has(slug)) return true;
|
|
127
|
+
|
|
128
|
+
const visited = new Set();
|
|
129
|
+
function reachesSlug(from) {
|
|
130
|
+
if (from === slug) return true;
|
|
131
|
+
if (visited.has(from)) return false;
|
|
132
|
+
visited.add(from);
|
|
133
|
+
const r = bySlug.get(from);
|
|
134
|
+
for (const dep of r?.dependsOn ?? []) {
|
|
135
|
+
for (const resolved of resolveDepSlug(dep, slugs)) {
|
|
136
|
+
if (reachesSlug(resolved)) return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
for (const target of resolvedTargets) {
|
|
142
|
+
if (reachesSlug(target)) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validates and computes the new dependsOn for a disposition change on an
|
|
149
|
+
* already-written PRD (the Scheduler UI's "promote to head" / "attach
|
|
150
|
+
* behind another chain" action). Pure — returns the new dependsOn value on
|
|
151
|
+
* success; the caller (scheduler.cjs's IPC handler) is responsible for
|
|
152
|
+
* actually persisting it via remote.updatePrd, which independently refuses
|
|
153
|
+
* a non-pending/quarantined row (defense in depth, not duplicated here).
|
|
154
|
+
*
|
|
155
|
+
* Refuses (ok: false) when:
|
|
156
|
+
* - `slug` isn't a known row.
|
|
157
|
+
* - the row itself is 'running' or 'completed' — never touch live or
|
|
158
|
+
* shipped work.
|
|
159
|
+
* - any of the row's CURRENT dependsOn entries already resolved
|
|
160
|
+
* ('completed') — the row may already be scheduler-eligible, and
|
|
161
|
+
* rewriting its ordering out from under a resolved blocker risks
|
|
162
|
+
* resurrecting/racing already-finished work (see this module's header).
|
|
163
|
+
* - the proposed dependsOn would create a cycle.
|
|
164
|
+
*/
|
|
165
|
+
function computeDispositionRewrite({ slug, disposition, dependsOn, rows }) {
|
|
166
|
+
const row = rows.find((r) => r.slug === slug);
|
|
167
|
+
if (!row) return { ok: false, error: `PRD not found: ${slug}` };
|
|
168
|
+
if (row.status === 'running' || row.status === 'completed') {
|
|
169
|
+
return { ok: false, error: `cannot change disposition of a "${row.status}" PRD — it is no longer part of the open plan` };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const slugs = rows.map((r) => r.slug);
|
|
173
|
+
for (const dep of row.dependsOn ?? []) {
|
|
174
|
+
for (const resolved of resolveDepSlug(dep, slugs)) {
|
|
175
|
+
const depRow = rows.find((r) => r.slug === resolved);
|
|
176
|
+
if (depRow?.status === 'completed') {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
error: `refusing to rewrite dependsOn: blocker "${resolved}" has already completed — this PRD may ` +
|
|
180
|
+
'already be eligible to run; changing its ordering now risks resurrecting finished work',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const newDependsOn = disposition === 'new-head' ? [] : Array.from(new Set(dependsOn ?? []));
|
|
187
|
+
if (newDependsOn.length && wouldCreateCycle(rows, slug, newDependsOn)) {
|
|
188
|
+
return { ok: false, error: `dependsOn ${JSON.stringify(newDependsOn)} would create a dependency cycle` };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { ok: true, dependsOn: newDependsOn };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = {
|
|
195
|
+
isIncomplete,
|
|
196
|
+
resolveChainTerminals,
|
|
197
|
+
wouldCreateCycle,
|
|
198
|
+
computeDispositionRewrite,
|
|
199
|
+
};
|
|
@@ -77,8 +77,8 @@ function splitFrontmatter(raw) {
|
|
|
77
77
|
* never emitted, which is how `scheduler_update_prd` clears a dependency.
|
|
78
78
|
*/
|
|
79
79
|
|
|
80
|
-
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine']);
|
|
81
|
-
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine'];
|
|
80
|
+
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition']);
|
|
81
|
+
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition'];
|
|
82
82
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
83
83
|
|
|
84
84
|
function indentOf(line) {
|
|
@@ -179,6 +179,12 @@ function applyKey(fm, key, after) {
|
|
|
179
179
|
// a no-op line for the overwhelming majority of PRDs that omit it.
|
|
180
180
|
if (v === true) fm.quietMachine = true;
|
|
181
181
|
return;
|
|
182
|
+
case 'disposition':
|
|
183
|
+
// Wave-authoring decision ('append'/'new-head') — see
|
|
184
|
+
// src/renderer/lib/prdFrontmatter.ts's mirror of this field for the
|
|
185
|
+
// full rationale. Only these two values are recognized.
|
|
186
|
+
if (v === 'append' || v === 'new-head') fm.disposition = v;
|
|
187
|
+
return;
|
|
182
188
|
}
|
|
183
189
|
}
|
|
184
190
|
|