@moretea-labs/forge 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +26 -0
- package/CLAUDE.md +2 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/agents/openai.yaml +1 -1
- package/assets/partials-agents/02-operating-mode.partial.md +2 -0
- package/assets/plugin-registry.v1.json +3 -3
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/check-architecture-sync.sh +17 -0
- package/assets/templates/helpers/inspect-project-state.ts +8 -6
- package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
- package/assets/templates/helpers/workflow-contract.ts +5 -1
- package/docs/architecture/CURRENT.md +12 -4
- package/docs/forge-plugin-management.md +1 -1
- package/docs/operations/controller-performance-and-502.md +19 -0
- package/docs/operations/homebrew.md +2 -2
- package/package.json +5 -2
- package/scripts/architecture-event.ts +2 -820
- package/scripts/architecture-queue.sh +2 -645
- package/scripts/archive-architecture-request.sh +2 -242
- package/scripts/archive-workflow.sh +1 -291
- package/scripts/benchmark-work-efficiency.ts +0 -0
- package/scripts/capability-config.ts +2 -392
- package/scripts/capability-resolver.ts +2 -503
- package/scripts/capture-plan.sh +1 -332
- package/scripts/check-agent-tooling.sh +2 -1531
- package/scripts/check-architecture-sync.sh +2 -302
- package/scripts/check-brain-manifest.sh +2 -253
- package/scripts/check-context-files.sh +2 -47
- package/scripts/check-deploy-sql-order.sh +2 -115
- package/scripts/check-runtime-architecture.mjs +243 -16
- package/scripts/check-skill-version.ts +3 -191
- package/scripts/check-source-duplication.mjs +61 -0
- package/scripts/check-stable-baseline.ts +0 -0
- package/scripts/check-task-sync.sh +2 -63
- package/scripts/check-task-workflow.sh +2 -1000
- package/scripts/codex-handoff-resume.sh +2 -267
- package/scripts/context-contract-sync.sh +2 -514
- package/scripts/contract-run.ts +2 -463
- package/scripts/contract-worktree.sh +1 -702
- package/scripts/ensure-task-workflow.sh +1 -1192
- package/scripts/inspect-project-state.ts +4 -279
- package/scripts/lib/project-init-lib.sh +7 -2
- package/scripts/lib/run-forge-helper.sh +19 -0
- package/scripts/lib/run-forge-helper.ts +26 -0
- package/scripts/migrate-project-template.sh +1 -33
- package/scripts/migrate-workflow-docs.ts +4 -412
- package/scripts/new-plan.sh +1 -180
- package/scripts/new-spec.sh +1 -47
- package/scripts/new-sprint.sh +1 -46
- package/scripts/plan-to-todo.sh +1 -771
- package/scripts/prepare-codex-handoff.sh +2 -201
- package/scripts/prepare-handoff.sh +1 -78
- package/scripts/refresh-current-status.sh +2 -467
- package/scripts/run-governed-gate.ts +3 -0
- package/scripts/select-agent-context-blocks.sh +2 -64
- package/scripts/ship-worktrees.sh +1 -531
- package/scripts/sprint-backlog.sh +1 -834
- package/scripts/stage-runtime-release.ts +2 -1
- package/scripts/summarize-failures.sh +2 -123
- package/scripts/switch-plan.sh +1 -150
- package/scripts/sync-brain-docs.sh +2 -379
- package/scripts/sync-generated-authority.mjs +175 -0
- package/scripts/verify-contract.sh +2 -593
- package/scripts/verify-sprint.sh +1 -500
- package/scripts/workflow-contract.ts +4 -201
- package/scripts/workstream-sync.sh +2 -261
- package/src/cli/agent-jobs/executor-health.ts +1 -1
- package/src/cli/commands/plugin.ts +12 -3
- package/src/cli/controller/check-runner.ts +7 -2
- package/src/cli/controller/context-pack.ts +46 -21
- package/src/cli/github/contracts.ts +16 -0
- package/src/cli/github/github.ts +2 -16
- package/src/cli/github/session.ts +2 -1
- package/src/cli/local-bridge/facade-api.ts +37 -47
- package/src/cli/local-bridge/server.ts +2 -2
- package/src/cli/mcp/legacy-tool-service.ts +10 -33
- package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
- package/src/cli/mcp/repository-tools.ts +125 -101
- package/src/cli/mcp/server.ts +57 -13
- package/src/cli/mcp/tool-contract.ts +14 -0
- package/src/cli/mcp/toolset.ts +2 -2
- package/src/cli/mcp/transports/http.ts +23 -0
- package/src/cli/repositories/command-classifier.ts +2 -2
- package/src/cli/repositories/command-executor.ts +24 -6
- package/src/cli/repositories/repository-snapshot.ts +5 -2
- package/src/cli/repositories/structured-git.ts +57 -3
- package/src/cli/repository/inspector.ts +11 -3
- package/src/runtime/context/generic-lsp-provider.ts +1 -1
- package/src/runtime/context/semantic-navigation-contract.ts +61 -0
- package/src/runtime/context/semantic-navigation.ts +9 -61
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +69 -29
- package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
- package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
- package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
- package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
- package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
- package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
- package/src/runtime/control-plane/execution/work-handle-authority.ts +150 -0
- package/src/runtime/control-plane/execution/work-handle-store.ts +1 -1
- package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
- package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
- package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
- package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
- package/src/runtime/control-plane/execution/work-task-receipt.ts +4 -43
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +13 -3
- package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
- package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
- package/src/runtime/control-plane/facade/controller-round-relay.ts +164 -37
- package/src/runtime/control-plane/facade/controller-session-store.ts +183 -24
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +15 -1
- package/src/runtime/control-plane/facade/goal-workloop.ts +329 -148
- package/src/runtime/control-plane/facade/index.ts +2 -0
- package/src/runtime/control-plane/facade/plan-contract-store.ts +90 -22
- package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
- package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
- package/src/runtime/control-plane/facade/types.ts +68 -5
- package/src/runtime/control-plane/facade/work-contract-store.ts +3 -209
- package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
- package/src/runtime/control-plane/global-scheduler/maintenance.ts +14 -7
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +5 -1
- package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +13 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +15 -6
- package/src/runtime/control-plane/launcher/launch-reservation-store.ts +54 -5
- package/src/runtime/control-plane/launcher/thin-launcher.ts +56 -7
- package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
- package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
- package/src/runtime/control-plane/routing/route-policy.ts +18 -6
- package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
- package/src/runtime/evidence/process-check-execution.ts +18 -0
- package/src/runtime/execution/jobs/child-reference.ts +6 -2
- package/src/runtime/execution/process-runtime/check-result.ts +53 -18
- package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
- package/src/runtime/execution/process-runtime/command-facade.ts +39 -17
- package/src/runtime/execution/process-runtime/lightweight-managed.ts +20 -3
- package/src/runtime/execution/process-runtime/persisted-check.ts +4 -1
- package/src/runtime/execution/process-runtime/runtime.ts +10 -0
- package/src/runtime/execution/process-runtime/types.ts +2 -12
- package/src/runtime/execution/work-activity.ts +21 -0
- package/src/runtime/execution/workers/executor.ts +18 -13
- package/src/runtime/gateway/mcp/execution-tools.ts +35 -2930
- package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
- package/src/runtime/gateway/mcp/router.ts +2 -2
- package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +21 -2
- package/src/runtime/gateway/mcp/runtime-tools.ts +815 -1015
- package/src/runtime/plugins/app-store-connect-adapter.ts +93 -28
- package/src/runtime/plugins/browser-adapter.ts +256 -1083
- package/src/runtime/plugins/browser-handoff-host.ts +2 -25
- package/src/runtime/plugins/browser-handoff.ts +18 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +121 -16
- package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
- package/src/runtime/plugins/browser-native-session-service.ts +222 -0
- package/src/runtime/plugins/browser-runtime-contract.ts +2 -0
- package/src/runtime/plugins/browser-runtime.ts +10 -0
- package/src/runtime/plugins/browser-session-store.ts +121 -0
- package/src/runtime/plugins/browser-session-types.ts +98 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +4 -4
- package/src/runtime/plugins/external-adapter.ts +92 -58
- package/src/runtime/plugins/macos-capability-broker.ts +0 -8
- package/src/runtime/plugins/store.ts +66 -9
- package/src/runtime/plugins/types.ts +3 -1
- package/src/runtime/recovery/maintenance-executor.ts +61 -25
- package/src/runtime/root/package-connector-service.ts +46 -4
- package/src/runtime/root/package-runtime-release.ts +3 -2
- package/src/runtime/root/package-runtime-service.ts +48 -3
- package/src/runtime/root/release-manifest.ts +137 -16
- package/src/runtime/root/release-materialize.ts +109 -35
- package/src/runtime/root/types.ts +10 -0
- package/src/runtime/standalone-recovery/core.ts +425 -194
- package/src/runtime/standalone-recovery/entry.ts +1 -1
- package/src/runtime/standalone-recovery/installer.ts +1 -1
- package/src/runtime/workflow/schedules/engine.ts +113 -87
- package/src/runtime/workflow/schedules/settlement.ts +26 -23
- package/src/runtime/workflow/schedules/store.ts +79 -10
|
@@ -2,9 +2,10 @@ import { bindRepositoryEntities } from '../repositories/entity-migration';
|
|
|
2
2
|
import { bootstrapLocalProject, diagnoseLatestLocalProjectSource } from '../repositories/local-project-onboarding';
|
|
3
3
|
import { resolveEphemeralWorkspaceTarget } from '../repositories/ephemeral-workspace';
|
|
4
4
|
import type { ResolvedExecutionIdentity } from '../../runtime/control-plane/execution/execution-identity';
|
|
5
|
-
import {
|
|
5
|
+
import { assertNoBoundExecutionSessionMutation, resolveClaimedRepositoryWorkId, resolveExplicitClaimedRepositoryWork, type RepositoryWorkAttributionCaller } from '../../runtime/control-plane/execution/repository-work-attribution';
|
|
6
6
|
import { getWorkContract } from '../../runtime/control-plane/facade/work-contract-store';
|
|
7
|
-
import {
|
|
7
|
+
import { assertWorkPathsWithinScope } from '../../runtime/control-plane/execution/work-path-scope';
|
|
8
|
+
import { ensureRepositoryMutationWorkHandle } from '../../runtime/control-plane/execution/work-handle-authority';
|
|
8
9
|
import { isTerminalWorkContractStatus } from '../../runtime/control-plane/facade/types';
|
|
9
10
|
import { executeRepositoryCommand, previewRepositoryCommandExecution } from '../repositories/command-executor';
|
|
10
11
|
import { withControllerLock } from '../repositories/locks';
|
|
@@ -53,11 +54,12 @@ import {
|
|
|
53
54
|
executeRepositoryCommandViaProcessRuntime,
|
|
54
55
|
} from '../../runtime/execution/process-runtime/command-facade';
|
|
55
56
|
import { assessWorkMode, parseExplicitTaskMode } from '../controller/work-mode';
|
|
57
|
+
import { classifyRepositoryCommand } from '../repositories/command-classifier';
|
|
56
58
|
import { normalizeRepositoryCommand } from '../repositories/command-normalization';
|
|
57
59
|
import { readRepositoryRange } from '../repository/inspector';
|
|
58
60
|
import { getMcpPolicy } from './policy';
|
|
59
61
|
import { redactMcpText } from './redaction';
|
|
60
|
-
import type { CallToolResult, McpToolDefinition } from './
|
|
62
|
+
import type { CallToolResult, McpToolDefinition } from './tool-contract';
|
|
61
63
|
import {
|
|
62
64
|
boundUtf8,
|
|
63
65
|
compactCommandOutput,
|
|
@@ -287,87 +289,7 @@ function withResponseMeta(payload: Record<string, unknown>, startedAt: number):
|
|
|
287
289
|
return response;
|
|
288
290
|
}
|
|
289
291
|
|
|
290
|
-
export
|
|
291
|
-
sessionId?: string;
|
|
292
|
-
principalId?: string;
|
|
293
|
-
controllerInstanceId?: string;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function claimedExplicitWork(
|
|
297
|
-
controllerHome: string,
|
|
298
|
-
repoId: string,
|
|
299
|
-
caller: RepositoryToolCallerContext | undefined,
|
|
300
|
-
requestedWorkId: string,
|
|
301
|
-
) {
|
|
302
|
-
if (!caller?.principalId?.trim()) return undefined;
|
|
303
|
-
const work = getWorkContract({ controllerHome, repoId }, requestedWorkId);
|
|
304
|
-
if (!work || isTerminalWorkContractStatus(work.status)) throw new Error(`WORK_ATTRIBUTION_INVALID: ${requestedWorkId}`);
|
|
305
|
-
const owner = getControllerSession({ controllerHome, repoId }, requestedWorkId);
|
|
306
|
-
if (!owner) throw new Error(`WORK_CONTROLLER_CLAIM_REQUIRED: ${requestedWorkId}`);
|
|
307
|
-
if ((owner.principalId?.trim() || owner.controllerId) !== caller.principalId.trim()) throw new Error(`WORK_CONTROLLER_OWNERSHIP_MISMATCH: ${requestedWorkId}`);
|
|
308
|
-
return work;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function assertNoBoundExecutionSessionMutation(
|
|
312
|
-
controllerHome: string,
|
|
313
|
-
repository: ReturnType<typeof resolveRepositorySelection>,
|
|
314
|
-
caller?: RepositoryToolCallerContext,
|
|
315
|
-
): void {
|
|
316
|
-
if (!caller?.principalId?.trim() || !caller.sessionId?.trim()) return;
|
|
317
|
-
const executionSession = readExecutionSession(controllerHome, {
|
|
318
|
-
sessionId: caller.sessionId,
|
|
319
|
-
principalId: caller.principalId,
|
|
320
|
-
controllerInstanceId: caller.controllerInstanceId,
|
|
321
|
-
});
|
|
322
|
-
const workId = executionSession?.activeWorkId?.trim();
|
|
323
|
-
if (!workId || executionSession?.activeRepositoryId !== repository.repoId) return;
|
|
324
|
-
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
|
|
325
|
-
if (!work) throw new Error(`WORK_ATTRIBUTION_INVALID: ${workId}`);
|
|
326
|
-
if (isTerminalWorkContractStatus(work.status)) {
|
|
327
|
-
throw new Error(`WORK_ATTRIBUTION_TERMINAL: ${work.workId}:${work.status}`);
|
|
328
|
-
}
|
|
329
|
-
throw new Error(`WORK_ATTRIBUTION_REQUIRED: ${work.workId}; active execution session mutations must pass work_id explicitly`);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function claimedSessionWorkId(
|
|
333
|
-
controllerHome: string,
|
|
334
|
-
repository: ReturnType<typeof resolveRepositorySelection>,
|
|
335
|
-
caller?: RepositoryToolCallerContext,
|
|
336
|
-
explicitWorkId?: unknown,
|
|
337
|
-
): string | undefined {
|
|
338
|
-
const requestedWorkId = typeof explicitWorkId === 'string' ? explicitWorkId.trim() : '';
|
|
339
|
-
if (requestedWorkId) {
|
|
340
|
-
const work = claimedExplicitWork(controllerHome, repository.repoId, caller, requestedWorkId);
|
|
341
|
-
if (!work) return undefined;
|
|
342
|
-
if (work.checkoutId && work.checkoutId !== repository.activeCheckoutId) throw new Error(`WORK_CHECKOUT_MISMATCH: ${requestedWorkId}:${repository.activeCheckoutId}`);
|
|
343
|
-
return requestedWorkId;
|
|
344
|
-
}
|
|
345
|
-
if (!caller?.principalId?.trim()) return undefined;
|
|
346
|
-
if (caller.sessionId?.trim()) {
|
|
347
|
-
const executionSession = readExecutionSession(controllerHome, {
|
|
348
|
-
sessionId: caller.sessionId,
|
|
349
|
-
principalId: caller.principalId,
|
|
350
|
-
controllerInstanceId: caller.controllerInstanceId,
|
|
351
|
-
});
|
|
352
|
-
const workId = executionSession?.activeWorkId?.trim();
|
|
353
|
-
if (workId && executionSession?.activeRepositoryId === repository.repoId && (!executionSession.activeCheckoutId || executionSession.activeCheckoutId === repository.activeCheckoutId)) {
|
|
354
|
-
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
|
|
355
|
-
if (work && isTerminalWorkContractStatus(work.status)) {
|
|
356
|
-
throw new Error(`WORK_ATTRIBUTION_TERMINAL: ${work.workId}:${work.status}`);
|
|
357
|
-
}
|
|
358
|
-
const owner = getControllerSession({ controllerHome, repoId: repository.repoId }, workId);
|
|
359
|
-
if (work && owner?.sessionId === caller.sessionId && (owner.principalId?.trim() || owner.controllerId) === caller.principalId.trim()) return workId;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
const principal = caller.principalId.trim();
|
|
363
|
-
const candidates = listControllerSessions({ controllerHome, repoId: repository.repoId })
|
|
364
|
-
.filter((owner) => (owner.principalId?.trim() || owner.controllerId) === principal)
|
|
365
|
-
.map((owner) => ({ owner, work: getWorkContract({ controllerHome, repoId: repository.repoId }, owner.workId) }))
|
|
366
|
-
.filter((entry): entry is { owner: ReturnType<typeof listControllerSessions>[number]; work: NonNullable<ReturnType<typeof getWorkContract>> } => Boolean(entry.work && !isTerminalWorkContractStatus(entry.work.status) && (!entry.work.checkoutId || entry.work.checkoutId === repository.activeCheckoutId)));
|
|
367
|
-
if (candidates.length === 1) return candidates[0].work.workId;
|
|
368
|
-
if (candidates.length > 1) throw new Error(`WORK_ATTRIBUTION_AMBIGUOUS: principal ${principal} owns ${candidates.length} active Works on checkout ${repository.activeCheckoutId}`);
|
|
369
|
-
return undefined;
|
|
370
|
-
}
|
|
292
|
+
export type RepositoryToolCallerContext = RepositoryWorkAttributionCaller;
|
|
371
293
|
|
|
372
294
|
export function claimedSessionEditBinding(
|
|
373
295
|
controllerHome: string,
|
|
@@ -375,7 +297,7 @@ export function claimedSessionEditBinding(
|
|
|
375
297
|
caller?: RepositoryToolCallerContext,
|
|
376
298
|
explicitWorkId?: unknown,
|
|
377
299
|
): EditSessionBinding | undefined {
|
|
378
|
-
const workId =
|
|
300
|
+
const workId = resolveClaimedRepositoryWorkId(controllerHome, repository, caller, explicitWorkId);
|
|
379
301
|
if (!workId || !caller?.principalId?.trim()) return undefined;
|
|
380
302
|
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
|
|
381
303
|
if (!work) return undefined;
|
|
@@ -423,7 +345,7 @@ function resolveRepositorySelectionForClaimedWork(
|
|
|
423
345
|
allowSoleRepository: true,
|
|
424
346
|
});
|
|
425
347
|
if (!explicitWorkId || checkoutId) return repository;
|
|
426
|
-
const work =
|
|
348
|
+
const work = resolveExplicitClaimedRepositoryWork(controllerHome, repository, caller, explicitWorkId);
|
|
427
349
|
if (!work?.checkoutId || work.checkoutId === repository.activeCheckoutId) return repository;
|
|
428
350
|
return resolveRepositorySelection({
|
|
429
351
|
repoId: repository.repoId,
|
|
@@ -445,6 +367,32 @@ function rawDefaultBranchMergeCommand(repository: ReturnType<typeof resolveRepos
|
|
|
445
367
|
return /(?:^|[;&|]\s*)git\s+merge\b/.test(normalized.shellCommand ?? '');
|
|
446
368
|
}
|
|
447
369
|
|
|
370
|
+
interface HistoricalReadOnlyWorkContext {
|
|
371
|
+
workId: string;
|
|
372
|
+
status: string;
|
|
373
|
+
mode: 'historical_read_only_observation';
|
|
374
|
+
attribution: 'not_active_work';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function historicalReadOnlyWorkContext(
|
|
378
|
+
controllerHome: string,
|
|
379
|
+
repository: ReturnType<typeof resolveRepositorySelection>,
|
|
380
|
+
args: Record<string, unknown>,
|
|
381
|
+
): HistoricalReadOnlyWorkContext | undefined {
|
|
382
|
+
const requestedWorkId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
|
|
383
|
+
if (!requestedWorkId) return undefined;
|
|
384
|
+
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, requestedWorkId);
|
|
385
|
+
if (!work || !isTerminalWorkContractStatus(work.status)) return undefined;
|
|
386
|
+
const classification = classifyRepositoryCommand(args.command as string | string[], repository.defaultBranch);
|
|
387
|
+
if (classification.risk !== 'readonly') return undefined;
|
|
388
|
+
return {
|
|
389
|
+
workId: work.workId,
|
|
390
|
+
status: work.status,
|
|
391
|
+
mode: 'historical_read_only_observation',
|
|
392
|
+
attribution: 'not_active_work',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
448
396
|
function resolveRepositoryCommandTarget(
|
|
449
397
|
controllerHome: string,
|
|
450
398
|
args: Record<string, unknown>,
|
|
@@ -454,6 +402,7 @@ function resolveRepositoryCommandTarget(
|
|
|
454
402
|
repository: ReturnType<typeof resolveRepositorySelection>;
|
|
455
403
|
executionIdentity: ResolvedExecutionIdentity;
|
|
456
404
|
workspace?: { workspaceId: string; root: string; registered: false };
|
|
405
|
+
historicalWorkContext?: HistoricalReadOnlyWorkContext;
|
|
457
406
|
} {
|
|
458
407
|
const workspaceRoot = typeof args.workspace_root === 'string' ? args.workspace_root.trim() : '';
|
|
459
408
|
const checkoutId = typeof args.checkout_id === 'string' ? args.checkout_id.trim() : '';
|
|
@@ -474,10 +423,37 @@ function resolveRepositoryCommandTarget(
|
|
|
474
423
|
workspace: { workspaceId: target.workspaceId, root: target.canonicalRoot, registered: false },
|
|
475
424
|
};
|
|
476
425
|
}
|
|
426
|
+
// A terminal Work no longer owns mutation or checkout authority. An exact
|
|
427
|
+
// terminal work_id may still accompany a read-only observation immediately
|
|
428
|
+
// after finalize, so preserve it as explicit historical context while
|
|
429
|
+
// executing against the caller-selected/current repository checkout. Any
|
|
430
|
+
// non-read-only command continues through claimed Work resolution and fails
|
|
431
|
+
// closed for terminal attribution.
|
|
432
|
+
const selectedRepository = resolveRepositorySelection({
|
|
433
|
+
repoId: repoIdValue || undefined,
|
|
434
|
+
checkoutId: checkoutId || undefined,
|
|
435
|
+
controllerHome,
|
|
436
|
+
allowSoleRepository: true,
|
|
437
|
+
});
|
|
438
|
+
const historicalWorkContext = historicalReadOnlyWorkContext(controllerHome, selectedRepository, args);
|
|
439
|
+
if (historicalWorkContext) {
|
|
440
|
+
return {
|
|
441
|
+
repository: selectedRepository,
|
|
442
|
+
executionIdentity: {
|
|
443
|
+
schemaVersion: 1,
|
|
444
|
+
authority: 'repository',
|
|
445
|
+
repositoryId: selectedRepository.repoId,
|
|
446
|
+
checkoutId: selectedRepository.activeCheckoutId,
|
|
447
|
+
canonicalRoot: selectedRepository.canonicalRoot,
|
|
448
|
+
},
|
|
449
|
+
historicalWorkContext,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
477
453
|
const repository = resolveRepositorySelectionForClaimedWork(controllerHome, args, repoIdValue, caller);
|
|
478
454
|
const explicitWorkId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
|
|
479
455
|
const workId = explicitWorkId
|
|
480
|
-
?
|
|
456
|
+
? resolveClaimedRepositoryWorkId(controllerHome, repository, caller, explicitWorkId)
|
|
481
457
|
: undefined;
|
|
482
458
|
return {
|
|
483
459
|
repository,
|
|
@@ -615,6 +591,7 @@ function compactProcessCommandPayload(input: {
|
|
|
615
591
|
repoId: string;
|
|
616
592
|
checkoutId?: string;
|
|
617
593
|
workspace?: { workspaceId: string; root: string; registered: false };
|
|
594
|
+
historicalWorkContext?: HistoricalReadOnlyWorkContext;
|
|
618
595
|
processId?: string;
|
|
619
596
|
process?: unknown;
|
|
620
597
|
completed?: boolean;
|
|
@@ -653,6 +630,7 @@ function compactProcessCommandPayload(input: {
|
|
|
653
630
|
repoId: input.repoId,
|
|
654
631
|
...(input.checkoutId ? { checkoutId: input.checkoutId } : {}),
|
|
655
632
|
...(input.workspace ? { workspace: input.workspace } : {}),
|
|
633
|
+
...(input.historicalWorkContext ? { historicalWorkContext: input.historicalWorkContext } : {}),
|
|
656
634
|
...(input.processId ? { processId: input.processId } : {}),
|
|
657
635
|
...(completed ? { exitCode: input.exitCode ?? (ok ? 0 : 1) } : {}),
|
|
658
636
|
// A running default response otherwise loses its only dependency-bound
|
|
@@ -693,6 +671,7 @@ function compactProcessCommandPayload(input: {
|
|
|
693
671
|
repoId: input.repoId,
|
|
694
672
|
...(input.checkoutId ? { checkoutId: input.checkoutId } : {}),
|
|
695
673
|
...(input.workspace ? { workspace: input.workspace } : {}),
|
|
674
|
+
...(input.historicalWorkContext ? { historicalWorkContext: input.historicalWorkContext } : {}),
|
|
696
675
|
...(input.processId ? { processId: input.processId } : {}),
|
|
697
676
|
ok: input.ok,
|
|
698
677
|
exitCode: input.exitCode,
|
|
@@ -998,22 +977,42 @@ export async function callRepositoryTool(
|
|
|
998
977
|
case 'repository_safe_patch_apply': {
|
|
999
978
|
const repository = resolveRepositorySelectionForClaimedWork(controllerHome, args, repoIdValue, caller);
|
|
1000
979
|
const binding = safePatchEditBinding(controllerHome, repository, caller, args);
|
|
980
|
+
if (binding?.workId) {
|
|
981
|
+
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, binding.workId);
|
|
982
|
+
if (!work) throw new Error(`WORK_NOT_FOUND: ${binding.workId}`);
|
|
983
|
+
const requestedPaths = buildSafePatchPlan(repository, { operations: args.operations, chunkSize: args.chunk_size })
|
|
984
|
+
.chunks.flatMap((chunk) => chunk.paths);
|
|
985
|
+
assertWorkPathsWithinScope(work, requestedPaths, {
|
|
986
|
+
forbidden: 'WORK_MUTATION_FORBIDDEN_PATH',
|
|
987
|
+
outOfScope: 'WORK_MUTATION_PATH_OUT_OF_SCOPE',
|
|
988
|
+
});
|
|
989
|
+
}
|
|
1001
990
|
const applied = withControllerLock(
|
|
1002
991
|
controllerHome,
|
|
1003
992
|
{ scope: 'repository', repoId: repository.repoId },
|
|
1004
993
|
'mcp:repository_safe_patch_apply',
|
|
1005
|
-
() =>
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
994
|
+
() => {
|
|
995
|
+
if (binding?.workId) {
|
|
996
|
+
ensureRepositoryMutationWorkHandle({
|
|
997
|
+
controllerHome,
|
|
998
|
+
repository,
|
|
999
|
+
workId: binding.workId,
|
|
1000
|
+
principalId: binding.principalId ?? caller?.principalId ?? '',
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
return applySafePatch(repository, {
|
|
1004
|
+
sessionId: args.session_id,
|
|
1005
|
+
purpose: args.purpose,
|
|
1006
|
+
operations: args.operations,
|
|
1007
|
+
chunkSize: args.chunk_size,
|
|
1008
|
+
expectedRevision: args.expected_revision,
|
|
1009
|
+
allowedPaths: args.allowed_paths,
|
|
1010
|
+
continueOnError: args.continue_on_error,
|
|
1011
|
+
refreshFingerprints: args.refresh_fingerprints,
|
|
1012
|
+
recoverStaleSession: args.recover_stale_session,
|
|
1013
|
+
binding,
|
|
1014
|
+
});
|
|
1015
|
+
},
|
|
1017
1016
|
60_000,
|
|
1018
1017
|
);
|
|
1019
1018
|
const changedFiles = [
|
|
@@ -1098,7 +1097,7 @@ export async function callRepositoryTool(
|
|
|
1098
1097
|
case 'repository_command_execute': {
|
|
1099
1098
|
const explorationGuidance = fragmentedRepositoryExplorationGuidance(args.command);
|
|
1100
1099
|
const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue, caller);
|
|
1101
|
-
const { repository, executionIdentity } = target;
|
|
1100
|
+
const { repository, executionIdentity, historicalWorkContext } = target;
|
|
1102
1101
|
const explicitWorkId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
|
|
1103
1102
|
if (!explicitWorkId) assertNoBoundExecutionSessionMutation(controllerHome, repository, caller);
|
|
1104
1103
|
const deliveryWorkId = rawDefaultBranchMergeCommand(repository, args.command)
|
|
@@ -1107,6 +1106,23 @@ export async function callRepositoryTool(
|
|
|
1107
1106
|
if (deliveryWorkId) {
|
|
1108
1107
|
throw new Error(`WORK_DELIVERY_REQUIRES_FINALIZE: ${deliveryWorkId} must pass Work verification and use rh_work finalize before default-branch integration`);
|
|
1109
1108
|
}
|
|
1109
|
+
if (executionIdentity.workId) {
|
|
1110
|
+
const mutationClassification = classifyRepositoryCommand(args.command as string | string[], repository.defaultBranch);
|
|
1111
|
+
if (mutationClassification.risk === 'workspace_write' || mutationClassification.risk === 'destructive') {
|
|
1112
|
+
withControllerLock(
|
|
1113
|
+
controllerHome,
|
|
1114
|
+
{ scope: 'repository', repoId: repository.repoId },
|
|
1115
|
+
'mcp:repository_command_execute:mutation-authority',
|
|
1116
|
+
() => ensureRepositoryMutationWorkHandle({
|
|
1117
|
+
controllerHome,
|
|
1118
|
+
repository,
|
|
1119
|
+
workId: executionIdentity.workId!,
|
|
1120
|
+
principalId: caller?.principalId ?? '',
|
|
1121
|
+
}),
|
|
1122
|
+
60_000,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1110
1126
|
const timeoutMs = typeof args.timeout_ms === 'number'
|
|
1111
1127
|
? args.timeout_ms
|
|
1112
1128
|
: typeof args.timeout_ms === 'string'
|
|
@@ -1206,6 +1222,13 @@ export async function callRepositoryTool(
|
|
|
1206
1222
|
});
|
|
1207
1223
|
}
|
|
1208
1224
|
if (routeClass.route === 'process_direct' || routeClass.route === 'process_managed' || routeClass.route === 'durable') {
|
|
1225
|
+
const processRequestId = typeof args.request_id === 'string' ? args.request_id.trim() : '';
|
|
1226
|
+
if (executionIdentity.workId && !processRequestId) {
|
|
1227
|
+
throw new Error(
|
|
1228
|
+
`WORK_PROCESS_REQUEST_ID_REQUIRED: ${executionIdentity.workId}; `
|
|
1229
|
+
+ 'Work-attributed Process Runtime commands require a stable request_id so reconnect/retry can resume the original Process without duplicate execution.',
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1209
1232
|
const processResult = await executeRepositoryCommandViaProcessRuntime({
|
|
1210
1233
|
controllerHome,
|
|
1211
1234
|
repository,
|
|
@@ -1217,7 +1240,7 @@ export async function callRepositoryTool(
|
|
|
1217
1240
|
: typeof args.interactive_wait_ms === 'number' ? args.interactive_wait_ms : undefined,
|
|
1218
1241
|
maxOutputBytes,
|
|
1219
1242
|
returnHandleImmediately,
|
|
1220
|
-
requestId:
|
|
1243
|
+
requestId: processRequestId || undefined,
|
|
1221
1244
|
workId: executionIdentity.workId,
|
|
1222
1245
|
executionIdentity,
|
|
1223
1246
|
allowNonGitWorkspace: target.workspace !== undefined,
|
|
@@ -1272,6 +1295,7 @@ export async function callRepositoryTool(
|
|
|
1272
1295
|
repoId: repository.repoId,
|
|
1273
1296
|
checkoutId: repository.activeCheckoutId,
|
|
1274
1297
|
workspace: target.workspace,
|
|
1298
|
+
historicalWorkContext,
|
|
1275
1299
|
processId: handle?.processId,
|
|
1276
1300
|
process: detailLevel === 'detail' ? handle : undefined,
|
|
1277
1301
|
completed,
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type MultiRepositoryMcpToolContext,
|
|
14
14
|
} from './multi-repository';
|
|
15
15
|
import { callAccessTool } from './access-tools';
|
|
16
|
-
import {
|
|
16
|
+
import { callRepositoryToolWithPostFinalizeAttribution } from './post-finalize-work-attribution';
|
|
17
17
|
import { callRuntimeTool } from '../../runtime/gateway/mcp/runtime-tools';
|
|
18
18
|
import { callExecutionTool } from '../../runtime/gateway/mcp/execution-tools';
|
|
19
19
|
import { callProcessTool } from '../../runtime/gateway/mcp/process-tools';
|
|
@@ -450,6 +450,36 @@ export function sameCanonicalRuntimeProxyIdentity(
|
|
|
450
450
|
&& left.runtimeInstanceId === right.runtimeInstanceId;
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
export function canonicalRuntimeToolCallIsReplaySafe(name: string, args: Record<string, unknown>): boolean {
|
|
454
|
+
if (name !== 'repository_command_execute') return false;
|
|
455
|
+
const workId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
|
|
456
|
+
const requestId = typeof args.request_id === 'string' ? args.request_id.trim() : '';
|
|
457
|
+
return Boolean(workId && requestId);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function canonicalRuntimeToolCallFailureIsTransient(error: unknown): boolean {
|
|
461
|
+
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error ?? '');
|
|
462
|
+
return /Connection closed|socket[^\n]*closed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|UND_ERR_SOCKET|fetch failed|MCP_REQUEST_FAILED/i.test(message);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export async function callCanonicalRuntimeToolWithReplay<T>(input: {
|
|
466
|
+
name: string;
|
|
467
|
+
args: Record<string, unknown>;
|
|
468
|
+
call: () => Promise<T>;
|
|
469
|
+
reconnect: () => Promise<void>;
|
|
470
|
+
}): Promise<T> {
|
|
471
|
+
try {
|
|
472
|
+
return await input.call();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (!canonicalRuntimeToolCallIsReplaySafe(input.name, input.args)
|
|
475
|
+
|| !canonicalRuntimeToolCallFailureIsTransient(error)) {
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
await input.reconnect();
|
|
479
|
+
return await input.call();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
453
483
|
export interface CanonicalRuntimeProxy {
|
|
454
484
|
listTools(): Promise<{ tools: Tool[] }>;
|
|
455
485
|
callTool(ctx: MultiRepositoryMcpToolContext, name: string, args: Record<string, unknown>, timing?: MutableGatewayProxyTiming): Promise<CallToolResult>;
|
|
@@ -743,22 +773,36 @@ export function createCanonicalRuntimeProxy(ctx: MultiRepositoryMcpToolContext):
|
|
|
743
773
|
}
|
|
744
774
|
},
|
|
745
775
|
async callTool(callerContext, name, args, timing = {}) {
|
|
746
|
-
const
|
|
776
|
+
const prepared = await prepareLane(timing, name === 'process_wait' ? 'wait' : 'interactive');
|
|
777
|
+
const laneId = prepared.laneId;
|
|
778
|
+
let activeClient = prepared.client;
|
|
747
779
|
const gatewayCallStartedAtMs = Date.now();
|
|
748
780
|
const callStarted = performance.now();
|
|
781
|
+
const forwardedRequest = {
|
|
782
|
+
name,
|
|
783
|
+
arguments: args,
|
|
784
|
+
_meta: canonicalRuntimeForwardingMeta(callerContext),
|
|
785
|
+
} as Parameters<Client['callTool']>[0];
|
|
749
786
|
try {
|
|
750
787
|
// The outer Connector session is already bound to the Canonical Runtime
|
|
751
788
|
// schema and fenced by the Runtime-published fingerprint before dispatch.
|
|
752
789
|
// Caller identity stays request-scoped while the inner MCP lane is reused.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
790
|
+
// Only keyed Work-attributed Process Runtime commands may replay after a
|
|
791
|
+
// transient inner disconnect: the stable request_id resolves to the
|
|
792
|
+
// original Process instead of spawning the side effect twice.
|
|
793
|
+
const response = await callCanonicalRuntimeToolWithReplay({
|
|
794
|
+
name,
|
|
795
|
+
args,
|
|
796
|
+
call: async () => await activeClient.callTool(
|
|
797
|
+
forwardedRequest,
|
|
798
|
+
undefined,
|
|
799
|
+
{ timeout: CANONICAL_RUNTIME_TOOL_CALL_TIMEOUT_MS },
|
|
800
|
+
),
|
|
801
|
+
reconnect: async () => {
|
|
802
|
+
await closeLane(laneId, activeClient);
|
|
803
|
+
activeClient = await clientForCurrentRuntime(laneId, timing);
|
|
804
|
+
},
|
|
805
|
+
});
|
|
762
806
|
const result = response as unknown as CallToolResult;
|
|
763
807
|
timing.gatewayProxyCallMs = roundedMs(performance.now() - callStarted);
|
|
764
808
|
Object.assign(timing, deriveCanonicalForwardingTiming({
|
|
@@ -771,7 +815,7 @@ export function createCanonicalRuntimeProxy(ctx: MultiRepositoryMcpToolContext):
|
|
|
771
815
|
timing.gatewayProxyCallMs = roundedMs(performance.now() - callStarted);
|
|
772
816
|
// Poison only the failed lane. Other hot lanes remain available so one
|
|
773
817
|
// Runtime/session failure cannot collapse the whole Gateway proxy pool.
|
|
774
|
-
await closeLane(laneId,
|
|
818
|
+
await closeLane(laneId, activeClient);
|
|
775
819
|
throw error;
|
|
776
820
|
} finally {
|
|
777
821
|
scheduler.release(laneId);
|
|
@@ -892,7 +936,7 @@ export function createForgeMcpServerFromContext(
|
|
|
892
936
|
if (runtimeResult) return runtimeResult;
|
|
893
937
|
const durableResult = await routeDurableMcpCall(ctx, name, args);
|
|
894
938
|
if (durableResult) return durableResult;
|
|
895
|
-
const repositoryResult = await
|
|
939
|
+
const repositoryResult = await callRepositoryToolWithPostFinalizeAttribution(ctx.controllerHome, name, args, ctx);
|
|
896
940
|
if (repositoryResult) return repositoryResult;
|
|
897
941
|
return callMultiRepositoryTool(ctx, name, args);
|
|
898
942
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CallToolResult as SdkCallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
|
|
3
|
+
/** Transport-neutral MCP tool schema used by legacy, repository and Runtime surfaces. */
|
|
4
|
+
export interface McpToolDefinition {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
inputSchema: Record<string, unknown>;
|
|
8
|
+
annotations?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Bounded text-first result contract shared by Forge MCP adapters. */
|
|
12
|
+
export type CallToolResult = Omit<SdkCallToolResult, 'content'> & {
|
|
13
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
14
|
+
};
|
package/src/cli/mcp/toolset.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpToolDefinition } from './
|
|
1
|
+
import type { McpToolDefinition } from './tool-contract';
|
|
2
2
|
import type { MultiRepositoryMcpToolContext } from './multi-repository';
|
|
3
3
|
import { buildMultiRepositoryToolDefinitions } from './multi-repository';
|
|
4
4
|
import { accessToolDefinitions } from './access-tools';
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type ControllerAccessState,
|
|
8
8
|
} from './access-mode';
|
|
9
9
|
import { repositoryToolDefinitions } from './repository-tools';
|
|
10
|
-
import { runtimeToolDefinitions } from '../../runtime/gateway/mcp/runtime-
|
|
10
|
+
import { runtimeToolDefinitions } from '../../runtime/gateway/mcp/runtime-tool-definitions';
|
|
11
11
|
import { executionToolDefinitions } from '../../runtime/gateway/mcp/execution-tools';
|
|
12
12
|
import { processToolDefinitions } from '../../runtime/gateway/mcp/process-tools';
|
|
13
13
|
import { injectDurableCommandFields } from '../../runtime/gateway/mcp/router';
|
|
@@ -1125,6 +1125,29 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
|
|
|
1125
1125
|
});
|
|
1126
1126
|
});
|
|
1127
1127
|
|
|
1128
|
+
// Recovery probes this endpoint every few seconds. Keep it strictly in-memory
|
|
1129
|
+
// and transport-scoped: whole-control-plane readiness below may traverse every
|
|
1130
|
+
// repository projection and probe the local bridge, which must never become a
|
|
1131
|
+
// periodic event-loop load generator for the public MCP Connector itself.
|
|
1132
|
+
app.get('/transport-ready', (_req, res) => {
|
|
1133
|
+
const sessionSnapshot = sessionRegistry.snapshot();
|
|
1134
|
+
const sessionCapacityReady = sessionSnapshot.acceptingNewSessions
|
|
1135
|
+
&& runtimeStats.initializing < MAX_INITIALIZING_SESSIONS
|
|
1136
|
+
&& runtimeStats.activePosts < MAX_ACTIVE_POSTS;
|
|
1137
|
+
res.status(sessionCapacityReady ? 200 : 503).json({
|
|
1138
|
+
ready: sessionCapacityReady,
|
|
1139
|
+
profile: toolContext.policy.profile,
|
|
1140
|
+
gateway: sessionCapacityReady ? 'ready' : 'saturated',
|
|
1141
|
+
sessionCapacity: sessionSnapshot,
|
|
1142
|
+
runtimeCapacity: {
|
|
1143
|
+
initializing: runtimeStats.initializing,
|
|
1144
|
+
maximumInitializing: MAX_INITIALIZING_SESSIONS,
|
|
1145
|
+
activePosts: runtimeStats.activePosts,
|
|
1146
|
+
maximumActivePosts: MAX_ACTIVE_POSTS,
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1128
1151
|
app.get('/ready', async (_req, res) => {
|
|
1129
1152
|
const runtimeGeneration = currentRuntimeGeneration();
|
|
1130
1153
|
const sessionSnapshot = sessionRegistry.snapshot();
|
|
@@ -42,7 +42,7 @@ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
|
42
42
|
'count-objects', 'verify-pack', 'check-ignore',
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
|
-
function shellSegments(command: string): string[] {
|
|
45
|
+
export function shellSegments(command: string): string[] {
|
|
46
46
|
const segments: string[] = [];
|
|
47
47
|
let current = '';
|
|
48
48
|
let quote: "'" | '"' | undefined;
|
|
@@ -87,7 +87,7 @@ function shellSegments(command: string): string[] {
|
|
|
87
87
|
return segments;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
function shellWordsPreservingQuotes(segment: string): string[] {
|
|
90
|
+
export function shellWordsPreservingQuotes(segment: string): string[] {
|
|
91
91
|
const words: string[] = [];
|
|
92
92
|
let current = '';
|
|
93
93
|
let quote: "'" | '"' | undefined;
|
|
@@ -72,6 +72,8 @@ export interface ExecuteRepositoryCommandInput {
|
|
|
72
72
|
allowNonGitWorkspace?: boolean;
|
|
73
73
|
/** Internal: command-facade already classified this as a bounded local script; retain policy/snapshot/audit without durable Process state. */
|
|
74
74
|
allowOpaqueLocalScript?: boolean;
|
|
75
|
+
/** Internal/test seam: bound dirty-path fingerprint enrichment independently from child execution lifetime. */
|
|
76
|
+
snapshotFingerprintTimeoutMs?: number;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
|
|
@@ -98,6 +100,8 @@ export interface RepositoryCommandExecution {
|
|
|
98
100
|
authorizationDecision?: AuthorizationDecision;
|
|
99
101
|
approvalRequestId?: string;
|
|
100
102
|
infrastructureError?: { code: string; message: string };
|
|
103
|
+
/** Child execution completed, but post-execution repository evidence could not be proven. Mutation state is unknown until a later authoritative inspection. */
|
|
104
|
+
evidenceError?: { code: string; message: string };
|
|
101
105
|
externalPathUsages?: RepositoryCommandExternalPathUsage[];
|
|
102
106
|
}
|
|
103
107
|
|
|
@@ -307,6 +311,7 @@ async function prepareRepositoryCommandExecutionAsync(
|
|
|
307
311
|
? emptyWorkspaceSnapshot()
|
|
308
312
|
: await repositorySnapshotAsync(root, input.signal, {
|
|
309
313
|
pathFingerprints: !(classification.risk === 'readonly' && controllerHome === undefined),
|
|
314
|
+
fingerprintTimeoutMs: input.snapshotFingerprintTimeoutMs,
|
|
310
315
|
}));
|
|
311
316
|
return finalizePreparedExecution(
|
|
312
317
|
repository,
|
|
@@ -565,9 +570,19 @@ export async function executeRepositoryCommandAsync(
|
|
|
565
570
|
...hooks,
|
|
566
571
|
signal,
|
|
567
572
|
});
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
573
|
+
let after: RepositoryCommandSnapshot | undefined = input.allowNonGitWorkspace ? before : undefined;
|
|
574
|
+
let evidenceError: RepositoryCommandExecution['evidenceError'];
|
|
575
|
+
if (!input.allowNonGitWorkspace) {
|
|
576
|
+
try {
|
|
577
|
+
after = await repositorySnapshotAsync(root, signal?.aborted ? undefined : signal, {
|
|
578
|
+
fingerprintTimeoutMs: input.snapshotFingerprintTimeoutMs,
|
|
579
|
+
});
|
|
580
|
+
} catch (error) {
|
|
581
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
582
|
+
const code = message.includes(':') ? message.slice(0, message.indexOf(':')) : 'POST_EXECUTION_EVIDENCE_FAILED';
|
|
583
|
+
evidenceError = { code, message };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
571
586
|
const execution: RepositoryCommandExecution = {
|
|
572
587
|
...base,
|
|
573
588
|
status: 'executed',
|
|
@@ -578,8 +593,9 @@ export async function executeRepositoryCommandAsync(
|
|
|
578
593
|
stdout: result.stdout,
|
|
579
594
|
stderr: result.stderr,
|
|
580
595
|
after,
|
|
581
|
-
repositoryChanged: snapshotChanged(before, after),
|
|
582
|
-
changedPaths: changedSnapshotPaths(before, after),
|
|
596
|
+
repositoryChanged: after ? snapshotChanged(before, after) : undefined,
|
|
597
|
+
changedPaths: after ? changedSnapshotPaths(before, after) : undefined,
|
|
598
|
+
evidenceError,
|
|
583
599
|
policyDecision: 'allowed',
|
|
584
600
|
externalPathUsages: externalPathUsages.length > 0 ? externalPathUsages : undefined,
|
|
585
601
|
infrastructureError: result.timedOut
|
|
@@ -594,7 +610,9 @@ export async function executeRepositoryCommandAsync(
|
|
|
594
610
|
}
|
|
595
611
|
: undefined,
|
|
596
612
|
};
|
|
597
|
-
|
|
613
|
+
// Unknown post-execution mutation evidence is conservatively cache-invalidating;
|
|
614
|
+
// later reads must re-observe the repository rather than reuse a false unchanged state.
|
|
615
|
+
if (execution.repositoryChanged === true || execution.evidenceError) invalidateRepositoryReadCaches(root);
|
|
598
616
|
auditCommand(controllerHome, repository, execution);
|
|
599
617
|
return execution;
|
|
600
618
|
}
|
|
@@ -84,7 +84,7 @@ const MAX_DIRTY_PATHS = 200;
|
|
|
84
84
|
export async function repositorySnapshotAsync(
|
|
85
85
|
root: string,
|
|
86
86
|
signal?: AbortSignal,
|
|
87
|
-
options: { pathFingerprints?: boolean } = {},
|
|
87
|
+
options: { pathFingerprints?: boolean; fingerprintTimeoutMs?: number } = {},
|
|
88
88
|
): Promise<RepositoryCommandSnapshot> {
|
|
89
89
|
const [headResult, branchResult, statusResult, refsResult] = await Promise.all([
|
|
90
90
|
gitAsync(root, ['rev-parse', '--verify', 'HEAD'], signal), gitAsync(root, ['branch', '--show-current'], signal),
|
|
@@ -107,7 +107,10 @@ export async function repositorySnapshotAsync(
|
|
|
107
107
|
let pathFingerprints: Record<string, string> = {};
|
|
108
108
|
if (includePathFingerprints) {
|
|
109
109
|
const { computePathFingerprintsAsync } = await import('../../runtime/execution/thin-harness/fingerprint-worker');
|
|
110
|
-
const
|
|
110
|
+
const fingerprintTimeoutMs = typeof options.fingerprintTimeoutMs === 'number' && Number.isFinite(options.fingerprintTimeoutMs)
|
|
111
|
+
? Math.max(1, Math.trunc(options.fingerprintTimeoutMs))
|
|
112
|
+
: 5_000;
|
|
113
|
+
const fingerprint = await computePathFingerprintsAsync({ root, paths, statusByPath: Object.fromEntries(byPath), maxFileBytes: 256 * 1024, maxTotalBytes: 8 * 1024 * 1024, maxPaths: MAX_DIRTY_PATHS }, { signal, timeoutMs: fingerprintTimeoutMs });
|
|
111
114
|
pathFingerprints = fingerprint.pathFingerprints;
|
|
112
115
|
}
|
|
113
116
|
return { head, branch: branchResult.ok ? branchResult.stdout || null : null, status: statusResult.stdout, dirty: paths.length > 0, refsHash: createHash('sha256').update(refs).digest('hex'), paths, pathFingerprints };
|