@patwoodman/dsh-sdlc-flow-core 0.4.0-beta.23 → 0.4.0-beta.25
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/README.md +1 -1
- package/lib/escalation.js +14 -4
- package/lib/integration.js +12 -1
- package/lib/recovery.js +5 -2
- package/lib/runtime.js +110 -5
- package/lib/scheduler.js +8 -5
- package/lib/schema.js +44 -4
- package/lib/state-machine.js +12 -2
- package/lib/verifier.js +25 -1
- package/package.json +3 -3
package/README.md
CHANGED
package/lib/escalation.js
CHANGED
|
@@ -101,17 +101,27 @@ export function planEscalation(block, { mode = 'interactive' } = {}) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Apply a decision to a blocked task/project.
|
|
106
|
+
*
|
|
107
|
+
* Approval alone is not a corrective plan: `ESCALATED→READY` requires
|
|
108
|
+
* `correctivePlanApproved === true`, which is granted only when an explicit
|
|
109
|
+
* `correctivePlan` accompanies the approval. Rejecting/cancelling blocks.
|
|
110
|
+
*/
|
|
111
|
+
export function applyResolution({ resolution, optionId = null, decidedBy = null, correctivePlan = null } = {}) {
|
|
106
112
|
if (!['approved', 'rejected', 'cancelled', 'unavailable'].includes(resolution)) {
|
|
107
113
|
throw new Error(`invalid resolution: ${resolution}`);
|
|
108
114
|
}
|
|
109
|
-
const
|
|
115
|
+
const approved = resolution === 'approved';
|
|
116
|
+
const unblock = approved && correctivePlan != null;
|
|
110
117
|
return {
|
|
111
118
|
resolution,
|
|
112
119
|
optionId,
|
|
113
120
|
decidedBy,
|
|
114
|
-
|
|
121
|
+
correctivePlanApproved: unblock,
|
|
122
|
+
blockerResolved: unblock,
|
|
123
|
+
waiting_on: unblock ? null : 'user-decision',
|
|
124
|
+
taskTo: unblock ? 'READY' : (approved ? 'ESCALATED' : 'BLOCKED'),
|
|
115
125
|
projectTo: unblock ? null : 'BLOCKED',
|
|
116
126
|
};
|
|
117
127
|
}
|
package/lib/integration.js
CHANGED
|
@@ -64,9 +64,20 @@ export function integrationSteps(task, { mergeBase = null } = {}) {
|
|
|
64
64
|
*/
|
|
65
65
|
export function postMergeOutcome({ gatesPass, mergeCommit, failedTasks = [] } = {}) {
|
|
66
66
|
if (gatesPass) return { action: 'done', tag: true };
|
|
67
|
+
// Never guess the merge commit: `git revert -m 1 HEAD` on a missing SHA can
|
|
68
|
+
// revert an unrelated merge. A failed post-merge gate without a commit is an
|
|
69
|
+
// explicit refusal, not a HEAD fallback.
|
|
70
|
+
if (typeof mergeCommit !== 'string' || mergeCommit.trim() === '' || mergeCommit.trim() === 'HEAD') {
|
|
71
|
+
return {
|
|
72
|
+
action: 'refuse',
|
|
73
|
+
failedTasks,
|
|
74
|
+
reason: 'MISSING_MERGE_COMMIT',
|
|
75
|
+
detail: 'post-merge gate failure requires the merge commit to revert',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
67
78
|
return {
|
|
68
79
|
action: 'revert',
|
|
69
|
-
argv: ['git', 'revert', '-m', '1', mergeCommit
|
|
80
|
+
argv: ['git', 'revert', '-m', '1', mergeCommit],
|
|
70
81
|
failedTasks,
|
|
71
82
|
reason: 'POST_MERGE_GATE_FAILURE',
|
|
72
83
|
};
|
package/lib/recovery.js
CHANGED
|
@@ -78,11 +78,14 @@ export function reconcile({
|
|
|
78
78
|
transitions.push({ taskId: task.id, to: 'ESCALATED', actor: 'recovery', evidence: 'active-state-without-run' });
|
|
79
79
|
continue;
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
// A pid of null is not a live worker: in-process/API runs record no pid, and
|
|
82
|
+
// a crash leaves them unfinished forever. findOrphans already treats null as
|
|
83
|
+
// an orphan ('no-pid'); reconcile must agree or the resume path strands them.
|
|
84
|
+
const deadPid = run.pid == null || !isAlive(run.pid);
|
|
82
85
|
const missingWorktree = Boolean(task.worktree_path) && !worktreeExists(task.worktree_path);
|
|
83
86
|
const missingBranch = Boolean(task.branch) && !branchExists(task.branch);
|
|
84
87
|
|
|
85
|
-
if (deadPid) discrepancies.push({ taskId: task.id, kind: 'dead-worker', detail: `pid ${run.pid} is not alive` });
|
|
88
|
+
if (deadPid) discrepancies.push({ taskId: task.id, kind: 'dead-worker', detail: run.pid == null ? 'no-pid' : `pid ${run.pid} is not alive` });
|
|
86
89
|
if (missingWorktree) discrepancies.push({ taskId: task.id, kind: 'missing-worktree', detail: task.worktree_path });
|
|
87
90
|
if (missingBranch) discrepancies.push({ taskId: task.id, kind: 'missing-branch', detail: task.branch });
|
|
88
91
|
|
package/lib/runtime.js
CHANGED
|
@@ -15,12 +15,13 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
15
15
|
import { openDatabase, migrate, quickCheck } from './schema.js';
|
|
16
16
|
import { TaskStateService } from './state-machine.js';
|
|
17
17
|
import { ModelRouter } from './router.js';
|
|
18
|
-
import { budgetsForRisk, normalizeTaskBudget, PROJECT_BUDGET_DEFAULTS, checkDispatch, checkProjectBudget } from './budget.js';
|
|
18
|
+
import { budgetsForRisk, normalizeTaskBudget, PROJECT_BUDGET_DEFAULTS, checkDispatch, checkProjectBudget, evaluateRun } from './budget.js';
|
|
19
19
|
import { riskClass } from './risk.js';
|
|
20
20
|
import { InstanceLock, InstanceLockedError } from './instance-lock.js';
|
|
21
21
|
import { deriveWorkerResult, scopeVerdict, resolveClaims } from './worker-result.js';
|
|
22
22
|
import { exportMetrics } from './metrics.js';
|
|
23
23
|
import { GuardPlugin } from '@patwoodman/dsh-sdlc-flow-guard';
|
|
24
|
+
import { applyResolution } from './escalation.js';
|
|
24
25
|
|
|
25
26
|
export class RuntimeLockedError extends Error {
|
|
26
27
|
constructor(holder) {
|
|
@@ -213,13 +214,16 @@ export class Runtime {
|
|
|
213
214
|
|
|
214
215
|
// ── worker runs ──────────────────────────────────────────────────────────
|
|
215
216
|
|
|
216
|
-
startWorkerRun({ taskId, attempt, sessionId = null, pid = null, model = null, worktreePath = null, contextManifest = null, id = null }) {
|
|
217
|
+
startWorkerRun({ taskId, attempt, sessionId = null, pid = null, model = null, worktreePath = null, contextManifest = null, nonce = null, id = null }) {
|
|
217
218
|
const runId = id ?? `${taskId}-a${attempt}-${randomUUID().slice(0, 8)}`;
|
|
218
219
|
this.db.prepare(
|
|
219
220
|
`INSERT INTO worker_runs(id, task_id, attempt, session_id, pid, model_provider, model_id,
|
|
220
221
|
status, started_at, worktree_path, context_manifest_json)
|
|
221
222
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`,
|
|
222
|
-
).run(
|
|
223
|
+
).run(
|
|
224
|
+
runId, taskId, attempt, sessionId, pid, model?.provider ?? null, model?.id ?? null, this.now(), worktreePath,
|
|
225
|
+
contextManifest || nonce ? JSON.stringify({ ...(contextManifest ?? {}), ...(nonce ? { nonce } : {}) }) : null,
|
|
226
|
+
);
|
|
223
227
|
return runId;
|
|
224
228
|
}
|
|
225
229
|
|
|
@@ -251,9 +255,26 @@ export class Runtime {
|
|
|
251
255
|
* resolve the result channel, and persist. Never trusts the worker (L.1.2).
|
|
252
256
|
*/
|
|
253
257
|
ingestResult({ runId, filesChanged, contract = {}, outboxRaw = null, finalText = null, runMeta = {}, runtime = {} }) {
|
|
254
|
-
|
|
258
|
+
// Derive the run identity (and the nonce, when the dispatcher stored one)
|
|
259
|
+
// from the worker_runs row; caller-supplied fields still win.
|
|
260
|
+
const storedRun = this.getWorkerRun(runId);
|
|
261
|
+
const effectiveMeta = {
|
|
262
|
+
runId: storedRun?.id,
|
|
263
|
+
taskId: storedRun?.task_id,
|
|
264
|
+
attempt: storedRun?.attempt,
|
|
265
|
+
nonce: storedRun?.context_manifest?.nonce,
|
|
266
|
+
...runMeta,
|
|
267
|
+
};
|
|
268
|
+
const claims = resolveClaims({ outboxRaw, finalText, runMeta: effectiveMeta });
|
|
255
269
|
const result = deriveWorkerResult({ filesChanged, contract, claims: claims.claims, runtime });
|
|
256
270
|
const scope = scopeVerdict(result);
|
|
271
|
+
const taskRow = this.db.prepare('SELECT id, state, attempt, risk_class FROM tasks WHERE id = (SELECT task_id FROM worker_runs WHERE id = ?)').get(runId);
|
|
272
|
+
const spendBefore = taskRow ? this.spentForTask(taskRow.id) : { costUsd: 0, durationMs: 0, tokens: 0 };
|
|
273
|
+
const projectBefore = this.spentTotals();
|
|
274
|
+
const costKnown = typeof runtime.costUsd === 'number';
|
|
275
|
+
const tokensKnown = runtime.tokens != null && typeof runtime.tokens === 'object';
|
|
276
|
+
const tokensTotal = tokensKnown ? (runtime.tokens.in ?? 0) + (runtime.tokens.out ?? 0) : null;
|
|
277
|
+
|
|
257
278
|
this.finishWorkerRun(runId, {
|
|
258
279
|
status: result.status,
|
|
259
280
|
exitCode: runtime.exitCode ?? null,
|
|
@@ -262,7 +283,33 @@ export class Runtime {
|
|
|
262
283
|
costUsd: runtime.costUsd ?? 0,
|
|
263
284
|
result: { ...result, claims_source: claims.source, scope },
|
|
264
285
|
});
|
|
265
|
-
|
|
286
|
+
|
|
287
|
+
// Post-run budget accounting. The old path recorded any cost and still let
|
|
288
|
+
// the result proceed to VERIFYING/ACCEPTED; a per-attempt overrun or project
|
|
289
|
+
// breach is now evidence (FAILED) before acceptance can happen. Missing
|
|
290
|
+
// cost/tokens is UNKNOWN, never zero.
|
|
291
|
+
let budget = { action: 'unknown', available: false, reason: 'COST_UNKNOWN' };
|
|
292
|
+
if (taskRow && costKnown && tokensKnown) {
|
|
293
|
+
budget = {
|
|
294
|
+
available: true,
|
|
295
|
+
...evaluateRun({
|
|
296
|
+
attempt: taskRow.attempt ?? 0,
|
|
297
|
+
run: { costUsd: runtime.costUsd ?? 0, tokens: tokensTotal, durationMs: runtime.durationMs ?? 0 },
|
|
298
|
+
taskBudget: budgetsForRisk(taskRow.risk_class, this.config.budgets?.perTask),
|
|
299
|
+
taskSpend: { costUsd: spendBefore.costUsd },
|
|
300
|
+
projectBudget: this.config.budgets?.perProject ?? PROJECT_BUDGET_DEFAULTS,
|
|
301
|
+
projectSpend: projectBefore,
|
|
302
|
+
}),
|
|
303
|
+
};
|
|
304
|
+
const breach = budget.action === 'block' || budget.perAttemptOver === true;
|
|
305
|
+
if (breach && taskRow.state === 'IN_PROGRESS') {
|
|
306
|
+
this.state.transition(taskRow.id, 'FAILED', 'worker-mgr', {
|
|
307
|
+
hardFailure: true,
|
|
308
|
+
payload: { code: budget.reason ?? 'BUDGET_EXHAUSTED', budget_action: budget.action },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { result, scope, claimsSource: claims.source, budget };
|
|
266
313
|
}
|
|
267
314
|
|
|
268
315
|
// ── routing, budgets, model runs ─────────────────────────────────────────
|
|
@@ -375,6 +422,64 @@ export class Runtime {
|
|
|
375
422
|
: this.db.prepare('SELECT * FROM requirements ORDER BY created_at').all();
|
|
376
423
|
}
|
|
377
424
|
|
|
425
|
+
// ── escalations (decision authority stays human/lead) ───────────────────
|
|
426
|
+
|
|
427
|
+
/** Persist an escalation decision packet before it is put to the user. */
|
|
428
|
+
recordEscalation({ id, taskId = null, attempt = null, level = null, packet = null } = {}) {
|
|
429
|
+
if (typeof id !== 'string' || id === '') throw new Error('recordEscalation: id is required');
|
|
430
|
+
this.db.prepare(
|
|
431
|
+
`INSERT INTO escalations(id, task_id, attempt, level, packet_json, created_at)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
433
|
+
).run(id, taskId, attempt, level, JSON.stringify(packet ?? {}), this.now());
|
|
434
|
+
this.state.record({
|
|
435
|
+
taskId, actor: 'escalation-mgr', action: 'escalation-recorded',
|
|
436
|
+
payload: { escalation_id: id, level },
|
|
437
|
+
});
|
|
438
|
+
return this.getEscalation(id);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
getEscalation(id) {
|
|
442
|
+
const row = this.db.prepare('SELECT * FROM escalations WHERE id = ?').get(id);
|
|
443
|
+
if (!row) return null;
|
|
444
|
+
return { ...row, packet: safeJson(row.packet_json, {}) };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
getEscalations({ taskId = null } = {}) {
|
|
448
|
+
const rows = taskId
|
|
449
|
+
? this.db.prepare('SELECT * FROM escalations WHERE task_id = ? ORDER BY created_at').all(taskId)
|
|
450
|
+
: this.db.prepare('SELECT * FROM escalations ORDER BY created_at').all();
|
|
451
|
+
return rows.map((row) => ({ ...row, packet: safeJson(row.packet_json, {}) }));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Apply a recorded decision. Approval without an explicit corrective plan
|
|
456
|
+
* leaves the task ESCALATED (the state guard requires the plan); rejection
|
|
457
|
+
* blocks it. Returns the resolution context actually applied.
|
|
458
|
+
*/
|
|
459
|
+
resolveEscalation(id, { resolution, decidedBy = null, optionId = null, correctivePlan = null } = {}) {
|
|
460
|
+
const row = this.getEscalation(id);
|
|
461
|
+
if (!row) throw new Error(`resolveEscalation: unknown escalation ${id}`);
|
|
462
|
+
const ctx = applyResolution({ resolution, optionId, decidedBy, correctivePlan });
|
|
463
|
+
this.db.prepare(
|
|
464
|
+
'UPDATE escalations SET resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?',
|
|
465
|
+
).run(ctx.resolution, decidedBy, this.now(), id);
|
|
466
|
+
this.state.record({
|
|
467
|
+
taskId: row.task_id ?? null, actor: 'escalation-mgr', action: 'escalation-resolved',
|
|
468
|
+
payload: { escalation_id: id, resolution: ctx.resolution, decided_by: decidedBy, option_id: optionId },
|
|
469
|
+
});
|
|
470
|
+
const task = row.task_id ? this.getTask(row.task_id) : null;
|
|
471
|
+
let transition = null;
|
|
472
|
+
if (task && task.state === 'ESCALATED' && ctx.taskTo !== 'ESCALATED') {
|
|
473
|
+
transition = this.state.transition(row.task_id, ctx.taskTo, 'escalation-mgr', {
|
|
474
|
+
...ctx,
|
|
475
|
+
correctivePlanApproved: ctx.correctivePlanApproved,
|
|
476
|
+
userDecisionRequired: ctx.resolution !== 'approved',
|
|
477
|
+
payload: { escalation_id: id, resolution: ctx.resolution },
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return { ...ctx, transition };
|
|
481
|
+
}
|
|
482
|
+
|
|
378
483
|
// ── evidence ─────────────────────────────────────────────────────────────
|
|
379
484
|
|
|
380
485
|
recordTestResults(taskId, runId, gateResults) {
|
package/lib/scheduler.js
CHANGED
|
@@ -158,11 +158,14 @@ export function planStep(state, {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
// Recompute active set including transitions proposed above (READY is not
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
// Recompute active set including transitions proposed above (READY is not
|
|
162
|
+
// active). Copy every task: the working snapshot mutates state below, and
|
|
163
|
+
// aliasing the caller's objects would leak those mutations into its DAG.
|
|
164
|
+
const projected = tasks.map((task) => ({ ...task }));
|
|
165
|
+
for (const change of transitions) {
|
|
166
|
+
const task = projected.find((candidate) => candidate.id === change.taskId);
|
|
167
|
+
if (task) task.state = change.to;
|
|
168
|
+
}
|
|
166
169
|
|
|
167
170
|
let activeCount = activeTasks(projected).length;
|
|
168
171
|
for (const task of projected) {
|
package/lib/schema.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
14
|
+
import { copyFileSync, existsSync } from 'node:fs';
|
|
14
15
|
|
|
15
16
|
export const SCHEMA_VERSION = 1;
|
|
16
17
|
|
|
@@ -214,7 +215,28 @@ export const TABLES = [
|
|
|
214
215
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
215
216
|
* @returns {number} applied schema version
|
|
216
217
|
*/
|
|
218
|
+
export function schemaVersion(db) {
|
|
219
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'").get();
|
|
220
|
+
if (!table) return null;
|
|
221
|
+
const row = db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
|
|
222
|
+
return row?.version ?? null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Refuse a database written by a newer plugin before any DDL mutates it. */
|
|
226
|
+
export function assertSchemaCompatible(db) {
|
|
227
|
+
const version = schemaVersion(db);
|
|
228
|
+
if (version !== null && version > SCHEMA_VERSION) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`sdlc.db schema version ${version} is newer than this plugin (${SCHEMA_VERSION}); `
|
|
231
|
+
+ 'refusing to open or migrate it. Upgrade the plugin or restore a backup.',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return version;
|
|
235
|
+
}
|
|
236
|
+
|
|
217
237
|
export function migrate(db) {
|
|
238
|
+
// Check BEFORE pragmas/DDL: a future schema must not be partially mutated.
|
|
239
|
+
assertSchemaCompatible(db);
|
|
218
240
|
for (const pragma of PRAGMAS) {
|
|
219
241
|
try { db.exec(pragma); } catch { /* :memory: and read-only mounts may refuse WAL */ }
|
|
220
242
|
}
|
|
@@ -228,6 +250,13 @@ export function migrate(db) {
|
|
|
228
250
|
);
|
|
229
251
|
return SCHEMA_VERSION;
|
|
230
252
|
}
|
|
253
|
+
if (row.version < SCHEMA_VERSION) {
|
|
254
|
+
db.prepare('INSERT INTO schema_version(version, applied_at) VALUES (?, ?)').run(
|
|
255
|
+
SCHEMA_VERSION,
|
|
256
|
+
new Date().toISOString(),
|
|
257
|
+
);
|
|
258
|
+
return SCHEMA_VERSION;
|
|
259
|
+
}
|
|
231
260
|
return row.version;
|
|
232
261
|
}
|
|
233
262
|
|
|
@@ -261,9 +290,20 @@ export function quickCheck(db) {
|
|
|
261
290
|
*/
|
|
262
291
|
export function openDatabase(dbPath, { requireQuickCheck = true, DatabaseSync: Ctor = DatabaseSync } = {}) {
|
|
263
292
|
const db = new Ctor(dbPath);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
293
|
+
try {
|
|
294
|
+
// Integrity + compatibility BEFORE any write. A torn DB must refuse to open
|
|
295
|
+
// (B.2.3), and a newer schema must never be migrated by an older plugin.
|
|
296
|
+
const version = assertSchemaCompatible(db);
|
|
297
|
+
if (requireQuickCheck) quickCheck(db);
|
|
298
|
+
if (version !== null && version < SCHEMA_VERSION && dbPath !== ':memory:' && existsSync(dbPath)) {
|
|
299
|
+
try { copyFileSync(dbPath, `${dbPath}.bak-v${version}`); } catch { /* backup is best-effort; migration is still checked */ }
|
|
300
|
+
}
|
|
301
|
+
migrate(db);
|
|
302
|
+
return db;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
try { db.close(); } catch { /* already closed */ }
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
267
307
|
}
|
|
268
308
|
|
|
269
309
|
export function tableNames(db) {
|
|
@@ -277,4 +317,4 @@ export function triggerNames(db) {
|
|
|
277
317
|
.map((r) => r.name);
|
|
278
318
|
}
|
|
279
319
|
|
|
280
|
-
export default { migrate, quickCheck, openDatabase, SCHEMA_VERSION, DDL, TABLES };
|
|
320
|
+
export default { migrate, quickCheck, openDatabase, schemaVersion, assertSchemaCompatible, SCHEMA_VERSION, DDL, TABLES };
|
package/lib/state-machine.js
CHANGED
|
@@ -38,7 +38,7 @@ const G = {
|
|
|
38
38
|
launchFailed: (ctx = {}) => ctx.launchFailed === true,
|
|
39
39
|
hardFailure: (ctx = {}) => ctx.hardFailure === true,
|
|
40
40
|
gatesFailed: (ctx = {}) => ctx.gatesPass === false,
|
|
41
|
-
gatesPass: (ctx = {}) => ctx.gatesPass
|
|
41
|
+
gatesPass: (ctx = {}) => ctx.gatesPass === true,
|
|
42
42
|
loopMatched: (ctx = {}) => ctx.loopMatched === true,
|
|
43
43
|
mergeSlotFree: (ctx = {}) => ctx.mergeSlotFree !== false,
|
|
44
44
|
mergedAndGreen: (ctx = {}) => ctx.merged === true && ctx.postMergeGatesPass !== false,
|
|
@@ -277,8 +277,18 @@ export class TaskStateService {
|
|
|
277
277
|
const pairLegal = TASK_TRANSITIONS.some((t) => t.to === to && (t.from === from || t.from === '*'));
|
|
278
278
|
return fail(pairLegal ? `actor ${actor} may not perform ${from ?? '—'}→${to}` : `no transition ${from ?? '—'}→${to}`);
|
|
279
279
|
}
|
|
280
|
+
// Single-writer is a DATABASE invariant, not a caller promise. When the
|
|
281
|
+
// caller does not state the slot, derive it from the one MERGING task in
|
|
282
|
+
// the store; a second ACCEPTED→MERGING with `{}` must fail closed.
|
|
283
|
+
let effectiveCtx = ctx;
|
|
284
|
+
if (to === 'MERGING') {
|
|
285
|
+
// The caller may VETO a free slot, never grant one: the holder is read
|
|
286
|
+
// inside the same BEGIN IMMEDIATE transaction as the state write.
|
|
287
|
+
const holder = this.db.prepare("SELECT id FROM tasks WHERE state = 'MERGING' AND id <> ?").get(taskId);
|
|
288
|
+
effectiveCtx = { ...ctx, mergeSlotFree: ctx.mergeSlotFree !== false && holder == null };
|
|
289
|
+
}
|
|
280
290
|
const guard = GUARDS[entry.guard] ?? GUARDS.always;
|
|
281
|
-
if (!guard(
|
|
291
|
+
if (!guard(effectiveCtx)) return fail(`guard ${entry.guard} rejected ${from ?? '—'}→${to}`);
|
|
282
292
|
|
|
283
293
|
const attemptDelta = to === 'READY' && (from === 'FAILED' || from === 'ESCALATED') ? 1 : 0;
|
|
284
294
|
const ts = this.now();
|
package/lib/verifier.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* This module is the pure decision table; the DSH service wires it to the DB.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { violatesBehavioralTestRule } from './risk.js';
|
|
8
|
+
import { violatesBehavioralTestRule, requiredGates as riskRequiredGates } from './risk.js';
|
|
9
9
|
|
|
10
10
|
export const REJECTION_CODES = [
|
|
11
11
|
'SCOPE_VIOLATION',
|
|
@@ -38,6 +38,7 @@ export function decideAcceptance({
|
|
|
38
38
|
claims = null,
|
|
39
39
|
runtimeStatus = 'completed',
|
|
40
40
|
blockedBySkippedGate = false,
|
|
41
|
+
requiredGates = null,
|
|
41
42
|
} = {}) {
|
|
42
43
|
const reasons = [];
|
|
43
44
|
|
|
@@ -73,6 +74,29 @@ export function decideAcceptance({
|
|
|
73
74
|
if (failedGates.length) {
|
|
74
75
|
return reject('FAILED', 'GATE_FAILED', failedGates.map(([g, r]) => `${g}: ${r.status}`));
|
|
75
76
|
}
|
|
77
|
+
// A required gate must be PRESENT and PASSING. The old check only required
|
|
78
|
+
// *some* evidence, so an R4 task with `syntax: pass` (or a required gate
|
|
79
|
+
// marked `skipped:*`) was accepted. Callers that know the risk-derived gate
|
|
80
|
+
// set pass it explicitly; skipped-but-not-required gates are excluded by the
|
|
81
|
+
// caller (requiredGates().filter(g => g.required)), preserving B.4/L.5.
|
|
82
|
+
// Default to the risk-derived floor. A caller that needs a deliberately
|
|
83
|
+
// reduced set (appearance-only, configured skips) passes it explicitly; R1
|
|
84
|
+
// is the only class that may be accepted without gate evidence.
|
|
85
|
+
const enforcedGates = Array.isArray(requiredGates)
|
|
86
|
+
? requiredGates
|
|
87
|
+
: (riskClass === 'R1' ? [] : riskRequiredGates(riskClass).filter((g) => g.required).map((g) => g.gate));
|
|
88
|
+
if (enforcedGates.length > 0) {
|
|
89
|
+
const missing = enforcedGates.filter((gate) => {
|
|
90
|
+
const result = gateResults[gate];
|
|
91
|
+
return !result || !String(result.status).startsWith('pass');
|
|
92
|
+
});
|
|
93
|
+
if (missing.length) {
|
|
94
|
+
if (Object.keys(gateResults).length === 0) {
|
|
95
|
+
return reject('FAILED', 'GATE_FAILED', ['no gate evidence recorded']);
|
|
96
|
+
}
|
|
97
|
+
return reject('FAILED', 'SKIPPED_GATE_NOT_PASSED', missing.map((gate) => `${gate}: ${gateResults[gate]?.status ?? 'missing'}`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
76
100
|
const requiredButMissing = Object.keys(gateResults).length === 0;
|
|
77
101
|
if (requiredButMissing && riskClass !== 'R1') {
|
|
78
102
|
return reject('FAILED', 'GATE_FAILED', ['no gate evidence recorded']);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patwoodman/dsh-sdlc-flow-core",
|
|
3
|
-
"version": "0.4.0-beta.
|
|
3
|
+
"version": "0.4.0-beta.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Host services (tasks, scheduler, router, verifier, integration, evidence, escalation, worker-mgr). NEVER installed into a worker profile.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"icon": "icon.svg",
|
|
42
42
|
"engines": {
|
|
43
|
-
"node": ">=22.
|
|
43
|
+
"node": ">=22.13.0"
|
|
44
44
|
},
|
|
45
45
|
"publishConfig": {
|
|
46
46
|
"access": "public"
|
|
@@ -50,6 +50,6 @@
|
|
|
50
50
|
"url": "https://github.com/pwoodman/dsh-sdlc/issues"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@patwoodman/dsh-sdlc-flow-guard": "0.4.0-beta.
|
|
53
|
+
"@patwoodman/dsh-sdlc-flow-guard": "0.4.0-beta.25"
|
|
54
54
|
}
|
|
55
55
|
}
|