@ran-sh/dsh-crew 0.5.3 → 0.5.5
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 +76 -93
- package/README.zh.md +73 -93
- package/docs/installation.md +63 -63
- package/package.json +1 -1
- package/src/extension-contract.mjs +7 -5
- package/src/install/install.mjs +18 -6
- package/src/install/windows-startup.mjs +148 -115
- package/src/install/zcode.mjs +366 -316
- package/src/runtime-identity.mjs +1 -1
- package/src/server.mjs +39 -39
- package/src/workflow-runtime.mjs +71 -53
- package/src/workspace-isolation.mjs +43 -7
package/src/runtime-identity.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Keep this module pure and dependency-free so Hub, MCP and tests all use the
|
|
9
9
|
// exact same compatibility rules.
|
|
10
10
|
|
|
11
|
-
export const RUNTIME_VERSION = '0.5.
|
|
11
|
+
export const RUNTIME_VERSION = '0.5.5';
|
|
12
12
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
13
13
|
|
|
14
14
|
export const HUB_CAPABILITIES = Object.freeze([
|
package/src/server.mjs
CHANGED
|
@@ -26,9 +26,9 @@ import { buildReviewTask } from './information-flow.mjs';
|
|
|
26
26
|
import { projectWorkflowView } from './job-contracts.mjs';
|
|
27
27
|
import { loadRoleProfiles, resolveRoleProfile } from './role-profiles.mjs';
|
|
28
28
|
import { loadWorkspaceContexts, resolveWorkspaceContext, buildWorkspaceTask, addContextReferences, isSafeBranchName } from './workspace-context.mjs';
|
|
29
|
-
import { buildExtensionContract } from './extension-contract.mjs';
|
|
30
|
-
import { assessWorkspaceReadiness } from './workspace-readiness.mjs';
|
|
31
|
-
import { detectOrchestrator } from './orchestrator.mjs';
|
|
29
|
+
import { buildExtensionContract } from './extension-contract.mjs';
|
|
30
|
+
import { assessWorkspaceReadiness } from './workspace-readiness.mjs';
|
|
31
|
+
import { detectOrchestrator } from './orchestrator.mjs';
|
|
32
32
|
|
|
33
33
|
const server = new McpServer({ name: 'dsh-crew', version: RUNTIME_VERSION });
|
|
34
34
|
|
|
@@ -45,11 +45,11 @@ const workspaceSchema = z.object({
|
|
|
45
45
|
branch: z.string().refine(isSafeBranchName, 'invalid git branch name').optional(),
|
|
46
46
|
worktree: z.enum(['auto', 'existing', 'none']).optional(),
|
|
47
47
|
}).optional().describe('Per-job workspace overrides. auto isolates coding Workers; existing/none use the supplied workspace.');
|
|
48
|
-
const constraintsSchema = z.object({
|
|
49
|
-
timeout_seconds: z.number().int().positive().max(7200).optional(),
|
|
50
|
-
allow_fallback: z.boolean().optional(),
|
|
51
|
-
allow_no_changes: z.boolean().optional().describe('Explicitly allow a verified read-only or analysis job to succeed with zero workspace changes.'),
|
|
52
|
-
}).optional().describe('Per-job constraints override profile and session defaults.');
|
|
48
|
+
const constraintsSchema = z.object({
|
|
49
|
+
timeout_seconds: z.number().int().positive().max(7200).optional(),
|
|
50
|
+
allow_fallback: z.boolean().optional(),
|
|
51
|
+
allow_no_changes: z.boolean().optional().describe('Explicitly allow a verified read-only or analysis job to succeed with zero workspace changes.'),
|
|
52
|
+
}).optional().describe('Per-job constraints override profile and session defaults.');
|
|
53
53
|
|
|
54
54
|
// Session-level configuration. This MCP server process lives exactly as long
|
|
55
55
|
// as one Claude Code / Codex session, so plain memory IS session scope.
|
|
@@ -125,7 +125,7 @@ function text(obj) {
|
|
|
125
125
|
return { content: [{ type: 'text', text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2) }] };
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
const ORCHESTRATOR = detectOrchestrator();
|
|
128
|
+
const ORCHESTRATOR = detectOrchestrator();
|
|
129
129
|
|
|
130
130
|
function policyRejection(decision) {
|
|
131
131
|
return text({
|
|
@@ -222,10 +222,10 @@ function prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, timeout_s
|
|
|
222
222
|
profile_id: resolvedProfile.profile_id,
|
|
223
223
|
requested_isolation: requestedIsolation,
|
|
224
224
|
workspace_branch: workspaceOverride?.branch ?? withRefs.context?.default_branch ?? null,
|
|
225
|
-
timeout_seconds: effectiveTimeout,
|
|
226
|
-
allow_fallback: constraints?.allow_fallback ?? profileValue.fallback,
|
|
227
|
-
allow_no_changes: constraints?.allow_no_changes === true,
|
|
228
|
-
routing: profileValue.routing,
|
|
225
|
+
timeout_seconds: effectiveTimeout,
|
|
226
|
+
allow_fallback: constraints?.allow_fallback ?? profileValue.fallback,
|
|
227
|
+
allow_no_changes: constraints?.allow_no_changes === true,
|
|
228
|
+
routing: profileValue.routing,
|
|
229
229
|
review_strictness: profileValue.review_strictness,
|
|
230
230
|
workspace_context: withRefs.context,
|
|
231
231
|
},
|
|
@@ -324,12 +324,12 @@ async function buildConfigReport() {
|
|
|
324
324
|
const hubCompatibility = await hubStatus({ force: true });
|
|
325
325
|
let effectiveWorkerProvider = null;
|
|
326
326
|
let effectiveWorkerSelection = { flash: null, pro: null };
|
|
327
|
-
let providerResolutionError;
|
|
328
|
-
let providerCatalogChecked = false;
|
|
329
|
-
let providerCatalogBody = null;
|
|
330
|
-
let hubJobsChecked = false;
|
|
331
|
-
let hubJobsBody = null;
|
|
332
|
-
const workerProviderMode = globalConfig.worker_provider_mode ?? 'deepseek-official';
|
|
327
|
+
let providerResolutionError;
|
|
328
|
+
let providerCatalogChecked = false;
|
|
329
|
+
let providerCatalogBody = null;
|
|
330
|
+
let hubJobsChecked = false;
|
|
331
|
+
let hubJobsBody = null;
|
|
332
|
+
const workerProviderMode = globalConfig.worker_provider_mode ?? 'deepseek-official';
|
|
333
333
|
if (workerProviderMode === 'deepseek-official') {
|
|
334
334
|
effectiveWorkerSelection = {
|
|
335
335
|
flash: { provider: 'deepseek-official', model: 'deepseek-v4-flash', source: 'legacy-strict' },
|
|
@@ -338,7 +338,7 @@ async function buildConfigReport() {
|
|
|
338
338
|
} else if (hubCompatibility.compatible) {
|
|
339
339
|
try {
|
|
340
340
|
providerCatalogChecked = true;
|
|
341
|
-
const res = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/models
|
|
341
|
+
const res = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/models`, { signal: AbortSignal.timeout(800) });
|
|
342
342
|
const body = await res.json();
|
|
343
343
|
providerCatalogBody = body;
|
|
344
344
|
if (!body?.ok) providerResolutionError = body?.error ?? 'Unable to read Harness model catalog.';
|
|
@@ -359,27 +359,27 @@ async function buildConfigReport() {
|
|
|
359
359
|
providerCatalogChecked = true;
|
|
360
360
|
providerResolutionError = err?.message ?? String(err);
|
|
361
361
|
}
|
|
362
|
-
} else if (hubCompatibility.reachable) {
|
|
363
|
-
providerResolutionError = hubCompatibilityMessage(hubCompatibility);
|
|
364
|
-
}
|
|
365
|
-
if (hubCompatibility.compatible) {
|
|
366
|
-
try {
|
|
367
|
-
hubJobsChecked = true;
|
|
368
|
-
const jobsRes = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/jobs`, { signal: AbortSignal.timeout(800) });
|
|
369
|
-
hubJobsBody = await jobsRes.json();
|
|
370
|
-
} catch {
|
|
371
|
-
hubJobsChecked = true;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
effectiveWorkerProvider = effectiveWorkerSelection.flash?.provider ?? null;
|
|
375
|
-
const readinessMatrix = buildConfigReadinessMatrix({
|
|
362
|
+
} else if (hubCompatibility.reachable) {
|
|
363
|
+
providerResolutionError = hubCompatibilityMessage(hubCompatibility);
|
|
364
|
+
}
|
|
365
|
+
if (hubCompatibility.compatible) {
|
|
366
|
+
try {
|
|
367
|
+
hubJobsChecked = true;
|
|
368
|
+
const jobsRes = await fetch(`${globalConfig.hub_url}/_dsh/dsh-crew/jobs`, { signal: AbortSignal.timeout(800) });
|
|
369
|
+
hubJobsBody = await jobsRes.json();
|
|
370
|
+
} catch {
|
|
371
|
+
hubJobsChecked = true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
effectiveWorkerProvider = effectiveWorkerSelection.flash?.provider ?? null;
|
|
375
|
+
const readinessMatrix = buildConfigReadinessMatrix({
|
|
376
376
|
hubCompatibility,
|
|
377
377
|
workerProviderMode,
|
|
378
|
-
providerCatalogChecked,
|
|
379
|
-
providerCatalogBody,
|
|
380
|
-
hubJobsChecked,
|
|
381
|
-
hubJobsBody,
|
|
382
|
-
});
|
|
378
|
+
providerCatalogChecked,
|
|
379
|
+
providerCatalogBody,
|
|
380
|
+
hubJobsChecked,
|
|
381
|
+
hubJobsBody,
|
|
382
|
+
});
|
|
383
383
|
const roleProfiles = loadRoleProfiles();
|
|
384
384
|
const workspaceReadiness = await assessWorkspaceReadiness({ cwd: process.cwd() });
|
|
385
385
|
const extensionContract = buildExtensionContract({
|
package/src/workflow-runtime.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// appear here.
|
|
16
16
|
|
|
17
17
|
import { resolveModelPolicy, shouldAutoReview, getRoleState } from './policy.mjs';
|
|
18
|
-
import { applyWorkspaceEvidence, buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
|
|
18
|
+
import { applyWorkspaceEvidence, buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
|
|
19
19
|
import { parseDeliveryReport } from './delivery.mjs';
|
|
20
20
|
import { classifyFailure } from './failure-classification.mjs';
|
|
21
21
|
import { createCanonicalJobEvent } from './job-contracts.mjs';
|
|
@@ -170,10 +170,10 @@ export function createWorkflowRuntime(adapters, {
|
|
|
170
170
|
requested_isolation: spec.requested_isolation ?? null,
|
|
171
171
|
workspace_branch: spec.workspace_branch ?? null,
|
|
172
172
|
timeout_seconds: spec.timeout_seconds ?? null,
|
|
173
|
-
profile_id: spec.profile_id ?? null,
|
|
174
|
-
allow_fallback: spec.allow_fallback !== false,
|
|
175
|
-
allow_no_changes: spec.allow_no_changes === true,
|
|
176
|
-
routing: spec.routing ?? 'auto',
|
|
173
|
+
profile_id: spec.profile_id ?? null,
|
|
174
|
+
allow_fallback: spec.allow_fallback !== false,
|
|
175
|
+
allow_no_changes: spec.allow_no_changes === true,
|
|
176
|
+
routing: spec.routing ?? 'auto',
|
|
177
177
|
review_strictness: spec.review_strictness ?? null,
|
|
178
178
|
workspace_context: spec.workspace_context ? { ...spec.workspace_context } : null,
|
|
179
179
|
effort: spec.effort ?? 'max',
|
|
@@ -202,6 +202,8 @@ export function createWorkflowRuntime(adapters, {
|
|
|
202
202
|
startedAt: now,
|
|
203
203
|
endedAt: null,
|
|
204
204
|
workspaceHandle: null,
|
|
205
|
+
workspaceReleasePromise: null,
|
|
206
|
+
cancelPromise: null,
|
|
205
207
|
waiters: [],
|
|
206
208
|
};
|
|
207
209
|
recordCanonical(job, 'job.created', { client_job_id: job.client_job_id }, null, now);
|
|
@@ -269,19 +271,26 @@ export function createWorkflowRuntime(adapters, {
|
|
|
269
271
|
return;
|
|
270
272
|
}
|
|
271
273
|
if (!job.workspaceHandle) return;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
274
|
+
if (job.workspaceReleasePromise) return job.workspaceReleasePromise;
|
|
275
|
+
const handle = job.workspaceHandle;
|
|
276
|
+
job.workspaceReleasePromise = (async () => {
|
|
277
|
+
try {
|
|
278
|
+
const r = await adapters.releaseWorkspace(handle);
|
|
279
|
+
if (!r || r.ok !== true) {
|
|
280
|
+
// A failed cleanup leaves the isolated workspace in place; say so
|
|
281
|
+
// truthfully instead of claiming a clean release.
|
|
282
|
+
job.cleanup_warning = r?.error ?? 'worktree cleanup failed';
|
|
283
|
+
job.workspace_retained = true;
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
job.cleanup_warning = err?.message ?? String(err);
|
|
278
287
|
job.workspace_retained = true;
|
|
288
|
+
} finally {
|
|
289
|
+
job.workspaceHandle = null;
|
|
290
|
+
job.workspaceReleasePromise = null;
|
|
279
291
|
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
job.workspace_retained = true;
|
|
283
|
-
}
|
|
284
|
-
job.workspaceHandle = null;
|
|
292
|
+
})();
|
|
293
|
+
return job.workspaceReleasePromise;
|
|
285
294
|
}
|
|
286
295
|
|
|
287
296
|
async function runWorkflow(job) {
|
|
@@ -313,6 +322,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
313
322
|
const before = alloc?.ok && alloc.isolation === 'worktree' ? await safeCapture(adapters, job.execution_cwd, job.base_revision) : null;
|
|
314
323
|
const reviewTask = adapters.buildReviewTask(job.original_task, null, { strictness: job.review_strictness ?? 'standard' });
|
|
315
324
|
const review = await runReviewerAttempt(job, reviewTask, config, before);
|
|
325
|
+
if (job.cancelling) { await cancelWorkflow(job); return; }
|
|
316
326
|
job.review = review;
|
|
317
327
|
if (job.review) transition(job, JOB_PHASES.READY, 'review complete');
|
|
318
328
|
finalize(job);
|
|
@@ -322,7 +332,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
322
332
|
let attempt = 0;
|
|
323
333
|
let escalationReason = null;
|
|
324
334
|
for (;;) {
|
|
325
|
-
if (job.cancelling) { cancelWorkflow(job); return; }
|
|
335
|
+
if (job.cancelling) { await cancelWorkflow(job); return; }
|
|
326
336
|
if (attempt > 0) transition(job, JOB_PHASES.RUNNING, `escalated attempt ${attempt}`);
|
|
327
337
|
const resolvedPolicy = resolveModelPolicy(config, 'worker', { attempt });
|
|
328
338
|
const fallbackPolicy = job.allow_fallback === false
|
|
@@ -356,7 +366,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
356
366
|
},
|
|
357
367
|
});
|
|
358
368
|
job.current_attempt_id = null;
|
|
359
|
-
if (job.cancelling) { cancelWorkflow(job); return; }
|
|
369
|
+
if (job.cancelling) { await cancelWorkflow(job); return; }
|
|
360
370
|
const attemptView = attemptRecord(ar, attempt);
|
|
361
371
|
job.attempts.push(attemptView);
|
|
362
372
|
recordCanonical(job, 'model.selected', {
|
|
@@ -375,17 +385,17 @@ export function createWorkflowRuntime(adapters, {
|
|
|
375
385
|
failJob(job, Object.assign(new Error(ar.error ?? 'infrastructure failure'), { code: WORKFLOW_ERROR_CODES.ATTEMPT_INFRA_FAILURE }));
|
|
376
386
|
return;
|
|
377
387
|
}
|
|
378
|
-
let outcome = ar.outcome ?? buildOutcome({
|
|
388
|
+
let outcome = ar.outcome ?? buildOutcome({
|
|
379
389
|
result: ar.result ?? '',
|
|
380
390
|
stopReason: ar.stopReason,
|
|
381
391
|
executionStatus: ar.status === 'done' ? 'completed' : 'failed',
|
|
382
392
|
});
|
|
383
393
|
transition(job, JOB_PHASES.VERIFYING, `attempt ${attempt} complete`);
|
|
384
394
|
|
|
385
|
-
let workspaceEvidenceAvailable = false;
|
|
386
|
-
let workspaceHasChanges = false;
|
|
387
|
-
|
|
388
|
-
// Capture the latest candidate after every attempt. Capture failure is
|
|
395
|
+
let workspaceEvidenceAvailable = false;
|
|
396
|
+
let workspaceHasChanges = false;
|
|
397
|
+
|
|
398
|
+
// Capture the latest candidate after every attempt. Capture failure is
|
|
389
399
|
// not allowed to delete the only recoverable state: retain the worktree
|
|
390
400
|
// and preserve the worker business result, while surfacing the warning.
|
|
391
401
|
if (alloc?.ok && alloc.isolation === 'worktree') {
|
|
@@ -395,23 +405,23 @@ export function createWorkflowRuntime(adapters, {
|
|
|
395
405
|
job.retain_workspace = true;
|
|
396
406
|
job.candidate = null;
|
|
397
407
|
job.events.push({ at: clock(), phase: job.phase, type: 'candidate/failed', attempt, message: 'candidate capture failed; worktree retained for recovery' });
|
|
398
|
-
} else {
|
|
399
|
-
job.candidate = candidate;
|
|
400
|
-
attemptView.candidate_fingerprint = candidate.fingerprint ?? null;
|
|
401
|
-
workspaceEvidenceAvailable = true;
|
|
402
|
-
workspaceHasChanges = Array.isArray(candidate.changed_files) && candidate.changed_files.length > 0;
|
|
403
|
-
if (candidate.complete === false || candidate.replayable === false) {
|
|
408
|
+
} else {
|
|
409
|
+
job.candidate = candidate;
|
|
410
|
+
attemptView.candidate_fingerprint = candidate.fingerprint ?? null;
|
|
411
|
+
workspaceEvidenceAvailable = true;
|
|
412
|
+
workspaceHasChanges = Array.isArray(candidate.changed_files) && candidate.changed_files.length > 0;
|
|
413
|
+
if (candidate.complete === false || candidate.replayable === false) {
|
|
404
414
|
job.retain_workspace = true;
|
|
405
415
|
job.events.push({ at: clock(), phase: job.phase, type: 'candidate/incomplete', attempt, message: 'candidate is not fully replayable; worktree retained' });
|
|
406
416
|
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
outcome = applyWorkspaceEvidence(outcome, {
|
|
410
|
-
evidenceAvailable: workspaceEvidenceAvailable,
|
|
411
|
-
hasChanges: workspaceHasChanges,
|
|
412
|
-
allowNoChanges: job.allow_no_changes,
|
|
413
|
-
});
|
|
414
|
-
job.outcome = outcome;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
outcome = applyWorkspaceEvidence(outcome, {
|
|
420
|
+
evidenceAvailable: workspaceEvidenceAvailable,
|
|
421
|
+
hasChanges: workspaceHasChanges,
|
|
422
|
+
allowNoChanges: job.allow_no_changes,
|
|
423
|
+
});
|
|
424
|
+
job.outcome = outcome;
|
|
415
425
|
|
|
416
426
|
const reviewRequested = !isReviewJob && shouldAutoReview(config);
|
|
417
427
|
const reviewerAuto = getRoleState(config, 'reviewer') !== 'disabled';
|
|
@@ -419,7 +429,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
419
429
|
job.decision = { step: decision.step, phase: decision.phase, reason: decision.reason };
|
|
420
430
|
job.events.push({ at: clock(), phase: job.phase, type: 'attempt/complete', attempt, decision: decision.step });
|
|
421
431
|
|
|
422
|
-
if (job.cancelling) { cancelWorkflow(job); return; }
|
|
432
|
+
if (job.cancelling) { await cancelWorkflow(job); return; }
|
|
423
433
|
if (decision.step === 'fail') {
|
|
424
434
|
const finalAttemptCode = ar.status === 'failed' ? attemptView.error_code : null;
|
|
425
435
|
failJob(job, Object.assign(new Error(`workflow failed: ${decision.reason}`), { code: finalAttemptCode }));
|
|
@@ -441,6 +451,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
441
451
|
const before = job.candidate;
|
|
442
452
|
const reviewTask = adapters.buildReviewTask(job.original_task, { outcome, candidate: job.candidate ?? null }, { strictness: job.review_strictness ?? 'standard' });
|
|
443
453
|
const review = await runReviewerAttempt(job, reviewTask, config, before, job.execution_cwd, job.base_revision);
|
|
454
|
+
if (job.cancelling) { await cancelWorkflow(job); return; }
|
|
444
455
|
job.review = review;
|
|
445
456
|
transition(job, JOB_PHASES.READY, decision.reason);
|
|
446
457
|
finalize(job);
|
|
@@ -451,7 +462,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
451
462
|
return;
|
|
452
463
|
}
|
|
453
464
|
} catch (err) {
|
|
454
|
-
if (job.cancelling) { cancelWorkflow(job); return; }
|
|
465
|
+
if (job.cancelling) { await cancelWorkflow(job); return; }
|
|
455
466
|
failJob(job, err);
|
|
456
467
|
} finally {
|
|
457
468
|
await releaseWorkspace(job);
|
|
@@ -546,17 +557,24 @@ export function createWorkflowRuntime(adapters, {
|
|
|
546
557
|
}
|
|
547
558
|
|
|
548
559
|
function cancelWorkflow(job) {
|
|
560
|
+
if (job.cancelPromise) return job.cancelPromise;
|
|
549
561
|
if (job.status !== 'running') return;
|
|
550
|
-
job.
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
562
|
+
job.cancelPromise = (async () => {
|
|
563
|
+
job.cancelling = true;
|
|
564
|
+
job.events.push({ at: clock(), phase: job.phase, type: 'cancel' });
|
|
565
|
+
const activeAttempt = job.current_attempt_id;
|
|
566
|
+
if (activeAttempt) {
|
|
567
|
+
try { await adapters.cancelAttempt?.(activeAttempt); } catch {}
|
|
568
|
+
}
|
|
569
|
+
await releaseWorkspace(job);
|
|
570
|
+
job.phase = JOB_PHASES.CANCELLED;
|
|
571
|
+
job.status = 'cancelled';
|
|
572
|
+
recordCanonical(job, 'job.cancelled', { active_attempt_id: activeAttempt ?? null });
|
|
573
|
+
job.endedAt = clock();
|
|
574
|
+
job.current_attempt_id = null;
|
|
575
|
+
for (const w of job.waiters.splice(0)) w();
|
|
576
|
+
})();
|
|
577
|
+
return job.cancelPromise;
|
|
560
578
|
}
|
|
561
579
|
|
|
562
580
|
function workflowView(job, { withResult = false } = {}) {
|
|
@@ -579,9 +597,9 @@ export function createWorkflowRuntime(adapters, {
|
|
|
579
597
|
current_model: job.attempts[job.attempts.length - 1]?.model ?? null,
|
|
580
598
|
model_class_hint: job.model_class_hint,
|
|
581
599
|
source: job.source,
|
|
582
|
-
profile_id: job.profile_id,
|
|
583
|
-
allow_no_changes: job.allow_no_changes,
|
|
584
|
-
routing: job.routing,
|
|
600
|
+
profile_id: job.profile_id,
|
|
601
|
+
allow_no_changes: job.allow_no_changes,
|
|
602
|
+
routing: job.routing,
|
|
585
603
|
workspace_context: job.workspace_context ? { ...job.workspace_context } : null,
|
|
586
604
|
workspace_branch: job.workspace_branch,
|
|
587
605
|
timeout_seconds: job.timeout_seconds,
|
|
@@ -660,9 +678,9 @@ export function createWorkflowRuntime(adapters, {
|
|
|
660
678
|
async function cancel(id) {
|
|
661
679
|
const job = jobs.get(id);
|
|
662
680
|
if (!job) return undefined;
|
|
663
|
-
cancelWorkflow(job);
|
|
664
681
|
const qi = queue.indexOf(job);
|
|
665
682
|
if (qi !== -1) queue.splice(qi, 1);
|
|
683
|
+
await cancelWorkflow(job);
|
|
666
684
|
return workflowView(job);
|
|
667
685
|
}
|
|
668
686
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { execFile } from 'node:child_process';
|
|
14
14
|
import { existsSync, rmSync } from 'node:fs';
|
|
15
|
-
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { readFile, lstat } from 'node:fs/promises';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
17
|
import { join, resolve, basename } from 'node:path';
|
|
18
18
|
import { randomBytes, createHash } from 'node:crypto';
|
|
@@ -106,6 +106,21 @@ function splitFirstTab(line) {
|
|
|
106
106
|
return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
async function firstLinkComponent(cwd, relPath) {
|
|
110
|
+
let current = resolve(cwd);
|
|
111
|
+
const segments = String(relPath ?? '').replace(/\\/g, '/').split('/').filter(Boolean);
|
|
112
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
113
|
+
current = join(current, segments[i]);
|
|
114
|
+
try {
|
|
115
|
+
const st = await lstat(current);
|
|
116
|
+
if (st.isSymbolicLink()) return segments.slice(0, i + 1).join('/');
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limit }) {
|
|
110
125
|
const tracked = [];
|
|
111
126
|
const sensitive = new Set();
|
|
@@ -125,6 +140,8 @@ async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limi
|
|
|
125
140
|
let out = '';
|
|
126
141
|
const redacted = [];
|
|
127
142
|
const incompleteReasons = [];
|
|
143
|
+
const untrackedFiles = [];
|
|
144
|
+
const seenLinks = new Set();
|
|
128
145
|
if (tracked.length > 0) {
|
|
129
146
|
const r = await runGit(run, ['diff', '--binary', base, '--', ...tracked], { cwd });
|
|
130
147
|
if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
|
|
@@ -137,6 +154,20 @@ async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limi
|
|
|
137
154
|
if (redacted.length > 0) incompleteReasons.push('sensitive_content_redacted');
|
|
138
155
|
|
|
139
156
|
for (const p of untracked) {
|
|
157
|
+
// Walk every path component with lstat so a symlink/junction anywhere in
|
|
158
|
+
// the path is never followed. Out-of-worktree targets are treated as
|
|
159
|
+
// non-replayable; only the link boundary is exposed.
|
|
160
|
+
const link = await firstLinkComponent(cwd, p);
|
|
161
|
+
if (link) {
|
|
162
|
+
if (!seenLinks.has(link)) {
|
|
163
|
+
seenLinks.add(link);
|
|
164
|
+
untrackedFiles.push(link);
|
|
165
|
+
out += `[UNTRACKED SYMLINK: ${link}]\n`;
|
|
166
|
+
incompleteReasons.push(`untracked_symlink:${link}`);
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
untrackedFiles.push(p);
|
|
140
171
|
if (isSensitivePath(p)) {
|
|
141
172
|
redacted.push(p);
|
|
142
173
|
out += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
|
|
@@ -154,13 +185,16 @@ async function buildCandidatePatch(run, { cwd, base, nameStatus, untracked, limi
|
|
|
154
185
|
if (!next.complete) incompleteReasons.push(next.reason ?? `untracked_unreplayable:${p}`);
|
|
155
186
|
}
|
|
156
187
|
|
|
188
|
+
// Fingerprint identity must represent the complete pre-truncation workspace
|
|
189
|
+
// diff, not the truncated patch bytes that are actually retained.
|
|
190
|
+
const workspaceDigest = createHash('sha256').update(out).digest('hex');
|
|
157
191
|
const truncated = Buffer.byteLength(out, 'utf8') > limit;
|
|
158
192
|
if (truncated) {
|
|
159
193
|
out = Buffer.from(out, 'utf8').subarray(0, limit).toString('utf8');
|
|
160
194
|
incompleteReasons.push('patch_truncated');
|
|
161
195
|
}
|
|
162
196
|
const complete = incompleteReasons.length === 0;
|
|
163
|
-
return { failed: false, patch: out, truncated, redacted, complete, incompleteReasons };
|
|
197
|
+
return { failed: false, patch: out, truncated, redacted, complete, incompleteReasons, untrackedFiles, workspaceDigest };
|
|
164
198
|
}
|
|
165
199
|
|
|
166
200
|
async function safeNewFilePatch(relPath, absPath) {
|
|
@@ -180,8 +214,8 @@ async function safeNewFilePatch(relPath, absPath) {
|
|
|
180
214
|
}
|
|
181
215
|
}
|
|
182
216
|
|
|
183
|
-
function candidateFingerprint({ base, nameStatus,
|
|
184
|
-
return createHash('sha256').update(`${base}\n${nameStatus ?? ''}\n${
|
|
217
|
+
function candidateFingerprint({ base, nameStatus, workspaceDigest }) {
|
|
218
|
+
return createHash('sha256').update(`${base}\n${nameStatus ?? ''}\n${workspaceDigest ?? ''}`).digest('hex');
|
|
185
219
|
}
|
|
186
220
|
|
|
187
221
|
export async function captureCandidate({ worktreePath, baseRevision, git, limit = DIFF_LIMIT } = {}) {
|
|
@@ -204,6 +238,8 @@ export async function captureCandidate({ worktreePath, baseRevision, git, limit
|
|
|
204
238
|
const built = await buildCandidatePatch(run, { cwd: worktreePath, base, nameStatus: nameStatus.stdout, untracked, limit });
|
|
205
239
|
if (built.failed) return { ok: false, reason: built.reason ?? CANDIDATE_CAPTURE_FAILED, error: built.error };
|
|
206
240
|
|
|
241
|
+
const untrackedFiles = built.untrackedFiles ?? untracked;
|
|
242
|
+
|
|
207
243
|
const nameStatusOut = nameStatus.stdout;
|
|
208
244
|
return {
|
|
209
245
|
ok: true,
|
|
@@ -211,17 +247,17 @@ export async function captureCandidate({ worktreePath, baseRevision, git, limit
|
|
|
211
247
|
base_revision: base,
|
|
212
248
|
committed_head: head.ok ? head.stdout.trim() : null,
|
|
213
249
|
worktree_path: worktreePath,
|
|
214
|
-
changed_files: [...new Set([...trackedIn(nameStatusOut,
|
|
250
|
+
changed_files: [...new Set([...trackedIn(nameStatusOut, untrackedFiles), ...built.redacted, ...untrackedFiles])],
|
|
215
251
|
name_status: nameStatusOut,
|
|
216
252
|
diff_stat: statRel.stdout,
|
|
217
253
|
patch: built.patch,
|
|
218
254
|
patch_truncated: built.truncated,
|
|
219
255
|
sensitive_paths_redacted: built.redacted,
|
|
220
|
-
untracked_files:
|
|
256
|
+
untracked_files: untrackedFiles,
|
|
221
257
|
complete: built.complete,
|
|
222
258
|
replayable: built.complete,
|
|
223
259
|
incomplete_reasons: built.incompleteReasons,
|
|
224
|
-
fingerprint: candidateFingerprint({ base, nameStatus: nameStatusOut,
|
|
260
|
+
fingerprint: candidateFingerprint({ base, nameStatus: nameStatusOut, workspaceDigest: built.workspaceDigest }),
|
|
225
261
|
candidate_commit: null,
|
|
226
262
|
};
|
|
227
263
|
}
|