@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from 'crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
2
|
import { withControllerLock } from '../../../cli/repositories/locks';
|
|
3
3
|
import { readRequirement } from '../persistence/requirement-store';
|
|
4
4
|
import {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
writeControlPlaneRecord,
|
|
8
8
|
type ControlPlaneRecord,
|
|
9
9
|
} from '../persistence/sqlite-store';
|
|
10
|
+
import { workHasActiveExecution } from '../../execution/work-activity';
|
|
10
11
|
import { controllerSessionBlocksRecovery, getControllerSession } from './controller-session-store';
|
|
11
12
|
import { getHandoffItem, listHandoffItems } from './handoff-inbox-store';
|
|
12
13
|
import { getWorkContract, readWorkContractStore } from './work-contract-store';
|
|
@@ -25,6 +26,7 @@ export const CONTROLLER_ROUND_DISPOSITIONS = [
|
|
|
25
26
|
] as const;
|
|
26
27
|
export type ControllerRoundDisposition = (typeof CONTROLLER_ROUND_DISPOSITIONS)[number];
|
|
27
28
|
|
|
29
|
+
export const CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR = 'CONTROLLER_RELAY_CLAIM_RELEASED_WITHOUT_DISPOSITION';
|
|
28
30
|
const CONTROLLER_DISPOSITION_COMPATIBILITY_PREFIX = 'controller.disposition:';
|
|
29
31
|
|
|
30
32
|
export function parseControllerDispositionCompatibilityCapability(
|
|
@@ -77,6 +79,8 @@ export interface ControllerRoundRelayRecord {
|
|
|
77
79
|
controllerInstanceId: string;
|
|
78
80
|
sessionId: string;
|
|
79
81
|
claimGeneration: number;
|
|
82
|
+
/** Opaque per-round capability. Rotates when Forge dispatches a new ChatGPT round. */
|
|
83
|
+
authorityId?: string;
|
|
80
84
|
stateFingerprint: string;
|
|
81
85
|
roundCount: number;
|
|
82
86
|
repeatedStateCount: number;
|
|
@@ -90,6 +94,7 @@ export interface ControllerRoundRelayRecord {
|
|
|
90
94
|
conversationUrl?: string;
|
|
91
95
|
blockedReason?: string;
|
|
92
96
|
lastError?: string;
|
|
97
|
+
nextRecoveryAt?: string;
|
|
93
98
|
submittedAt: string;
|
|
94
99
|
updatedAt: string;
|
|
95
100
|
dispatchedAt?: string;
|
|
@@ -147,11 +152,17 @@ const DEFAULT_MAX_REPEATED_STATE = 2;
|
|
|
147
152
|
const DEFAULT_MAX_FAILURES = 3;
|
|
148
153
|
const DEFAULT_UNCLOSED_ROUND_GRACE_MS = 10 * 60_000;
|
|
149
154
|
const MAX_UNCLOSED_ROUND_GRACE_MS = 60 * 60_000;
|
|
155
|
+
const DEFAULT_STALLED_RECOVERY_BACKOFF_MS = 60_000;
|
|
156
|
+
const MAX_STALLED_RECOVERY_BACKOFF_MS = 15 * 60_000;
|
|
150
157
|
|
|
151
158
|
function nowIso(options: ControllerRoundRelayStoreOptions): string {
|
|
152
159
|
return options.now?.() ?? new Date().toISOString();
|
|
153
160
|
}
|
|
154
161
|
|
|
162
|
+
function newControllerRoundAuthorityId(): string {
|
|
163
|
+
return `cra_${randomUUID().replace(/-/g, '')}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
function bounded(value: string | undefined, max: number): string | undefined {
|
|
156
167
|
const normalized = value?.trim();
|
|
157
168
|
return normalized ? normalized.slice(0, max) : undefined;
|
|
@@ -515,6 +526,7 @@ export function submitControllerRoundDisposition(
|
|
|
515
526
|
controllerInstanceId: authority.controllerInstanceId,
|
|
516
527
|
sessionId: authority.sessionId,
|
|
517
528
|
claimGeneration: authority.claimGeneration,
|
|
529
|
+
authorityId: existing.value.authorityId,
|
|
518
530
|
stateFingerprint,
|
|
519
531
|
roundCount,
|
|
520
532
|
repeatedStateCount,
|
|
@@ -567,6 +579,8 @@ export function beginInitialControllerRoundDispatch(
|
|
|
567
579
|
if (previous && ['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(previous.status)) {
|
|
568
580
|
throw new Error(`CONTROLLER_RELAY_ROUND_ALREADY_OPEN: ${relayScopeId}`);
|
|
569
581
|
}
|
|
582
|
+
const abandonedReleasedRound = previous?.status === 'failed'
|
|
583
|
+
&& previous.lastError === CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR;
|
|
570
584
|
const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
|
|
571
585
|
const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
|
|
572
586
|
const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
|
|
@@ -574,16 +588,16 @@ export function beginInitialControllerRoundDispatch(
|
|
|
574
588
|
const maxRepeatedState = previous ? Math.min(previous.maxRepeatedState, requestedMaxRepeatedState) : requestedMaxRepeatedState;
|
|
575
589
|
const maxFailures = previous ? Math.min(previous.maxFailures, requestedMaxFailures) : requestedMaxFailures;
|
|
576
590
|
const stateFingerprint = mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const consecutiveFailures = previous
|
|
591
|
+
// Ordinary later schedule/manual occurrences intentionally receive a fresh
|
|
592
|
+
// mechanical budget. A claimed round explicitly released without any semantic
|
|
593
|
+
// disposition is different: launcher_start is recovering the same abandoned
|
|
594
|
+
// relay chain, so it must consume round/repeated-state budget rather than reset
|
|
595
|
+
// it and thereby weaken repeated-state fencing.
|
|
596
|
+
const roundCount = abandonedReleasedRound ? previous!.roundCount + 1 : 1;
|
|
597
|
+
const repeatedStateCount = abandonedReleasedRound
|
|
598
|
+
? (previous!.stateFingerprint === stateFingerprint ? previous!.repeatedStateCount + 1 : 0)
|
|
599
|
+
: 0;
|
|
600
|
+
const consecutiveFailures = abandonedReleasedRound ? previous!.consecutiveFailures : 0;
|
|
587
601
|
let blockedReason: string | undefined;
|
|
588
602
|
if (roundCount > maxRounds) blockedReason = `round_budget_exhausted:${roundCount}>${maxRounds}`;
|
|
589
603
|
else if (repeatedStateCount >= maxRepeatedState) blockedReason = `repeated_state:${repeatedStateCount}>=${maxRepeatedState}`;
|
|
@@ -605,6 +619,7 @@ export function beginInitialControllerRoundDispatch(
|
|
|
605
619
|
controllerInstanceId: input.identity.controllerInstanceId.trim().slice(0, 240),
|
|
606
620
|
sessionId: input.identity.sessionId.trim().slice(0, 240),
|
|
607
621
|
claimGeneration: 0,
|
|
622
|
+
authorityId: newControllerRoundAuthorityId(),
|
|
608
623
|
stateFingerprint,
|
|
609
624
|
roundCount,
|
|
610
625
|
repeatedStateCount,
|
|
@@ -612,7 +627,7 @@ export function beginInitialControllerRoundDispatch(
|
|
|
612
627
|
maxRounds,
|
|
613
628
|
maxRepeatedState,
|
|
614
629
|
maxFailures,
|
|
615
|
-
reason: 'launcher_start_requested_continuation',
|
|
630
|
+
reason: abandonedReleasedRound ? 'launcher_start_recovered_abandoned_claim' : 'launcher_start_requested_continuation',
|
|
616
631
|
...(bounded(input.browserSessionId, 500) ? { browserSessionId: bounded(input.browserSessionId, 500) } : previous?.browserSessionId ? { browserSessionId: previous.browserSessionId } : {}),
|
|
617
632
|
...(bounded(input.conversationUrl, 2_000) ? { conversationUrl: bounded(input.conversationUrl, 2_000) } : previous?.conversationUrl ? { conversationUrl: previous.conversationUrl } : {}),
|
|
618
633
|
...(blockedReason ? { blockedReason } : {}),
|
|
@@ -651,7 +666,14 @@ export function beginControllerRoundRelayAfterRelease(
|
|
|
651
666
|
throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
|
|
652
667
|
}
|
|
653
668
|
const at = nowIso(options);
|
|
654
|
-
|
|
669
|
+
// A released round is semantically closed. The successor dispatch receives a
|
|
670
|
+
// fresh capability so a stale tab/conversation cannot mutate the next round.
|
|
671
|
+
const dispatching: ControllerRoundRelayRecord = {
|
|
672
|
+
...record,
|
|
673
|
+
authorityId: newControllerRoundAuthorityId(),
|
|
674
|
+
status: 'dispatching',
|
|
675
|
+
updatedAt: at,
|
|
676
|
+
};
|
|
655
677
|
writeControlPlaneRecord(options.controllerHome, {
|
|
656
678
|
namespace: NAMESPACE,
|
|
657
679
|
scope: options.repoId,
|
|
@@ -665,22 +687,82 @@ export function beginControllerRoundRelayAfterRelease(
|
|
|
665
687
|
});
|
|
666
688
|
}
|
|
667
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Mechanically close a claimed ChatGPT round only after its exact controller
|
|
692
|
+
* epoch has been released and durable state proves no controller lease remains.
|
|
693
|
+
* This never invents a semantic disposition; launcher_start must open a new round.
|
|
694
|
+
*/
|
|
695
|
+
export function reconcileControllerRoundAfterAbandonedRelease(
|
|
696
|
+
options: ControllerRoundRelayStoreOptions,
|
|
697
|
+
input: { workId: string; releasedSession: ControllerSession },
|
|
698
|
+
): ControllerRoundRelayRecord | undefined {
|
|
699
|
+
const initial = readRelayRecord(options, input.workId);
|
|
700
|
+
if (!initial || initial.value.status !== 'claimed') return undefined;
|
|
701
|
+
return relayLock(options, initial.value.relayScopeId, `controller-relay-abandoned-release:${input.releasedSession.controllerId}`, () => {
|
|
702
|
+
const current = readRelayRecord(options, input.workId);
|
|
703
|
+
if (!current || current.value.status !== 'claimed') return undefined;
|
|
704
|
+
const record = current.value;
|
|
705
|
+
const releasedPrincipal = input.releasedSession.principalId?.trim() || input.releasedSession.controllerId;
|
|
706
|
+
const releasedInstanceId = input.releasedSession.controllerInstanceId?.trim() || '';
|
|
707
|
+
if (
|
|
708
|
+
record.claimGeneration !== input.releasedSession.claimGeneration
|
|
709
|
+
|| record.controllerId !== input.releasedSession.controllerId
|
|
710
|
+
|| record.principalId !== releasedPrincipal
|
|
711
|
+
|| record.controllerInstanceId !== releasedInstanceId
|
|
712
|
+
|| record.sessionId !== input.releasedSession.sessionId
|
|
713
|
+
) {
|
|
714
|
+
throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
|
|
715
|
+
}
|
|
716
|
+
if (getControllerSession(options, input.workId)) {
|
|
717
|
+
throw new Error(`CONTROLLER_RELAY_ABANDONED_RELEASE_ACTIVE_CLAIM: ${input.workId}`);
|
|
718
|
+
}
|
|
719
|
+
const work = getWorkContract(options, input.workId);
|
|
720
|
+
if (!work || isTerminalWorkContractStatus(work.status)) return undefined;
|
|
721
|
+
|
|
722
|
+
const at = nowIso(options);
|
|
723
|
+
const abandoned: ControllerRoundRelayRecord = {
|
|
724
|
+
...record,
|
|
725
|
+
status: 'failed',
|
|
726
|
+
lastError: CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR,
|
|
727
|
+
claimedAt: undefined,
|
|
728
|
+
updatedAt: at,
|
|
729
|
+
};
|
|
730
|
+
writeControlPlaneRecord(options.controllerHome, {
|
|
731
|
+
namespace: NAMESPACE,
|
|
732
|
+
scope: options.repoId,
|
|
733
|
+
key: input.workId,
|
|
734
|
+
schemaVersion: SCHEMA_VERSION,
|
|
735
|
+
value: abandoned,
|
|
736
|
+
action: 'controller_round_relay_claim_abandoned_after_release',
|
|
737
|
+
expectedRevision: current.revision,
|
|
738
|
+
});
|
|
739
|
+
return abandoned;
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
668
743
|
export function finishControllerRoundRelayDispatch(
|
|
669
744
|
options: ControllerRoundRelayStoreOptions,
|
|
670
|
-
input: { workId: string; ok: boolean; browserSessionId?: string; conversationUrl?: string; error?: string },
|
|
745
|
+
input: { workId: string; ok: boolean; browserSessionId?: string; conversationUrl?: string; error?: string; recovery?: boolean; nowMs?: number },
|
|
671
746
|
): ControllerRoundRelayRecord | undefined {
|
|
672
747
|
const initial = readRelayRecord(options, input.workId);
|
|
673
748
|
if (!initial || initial.value.status !== 'dispatching') return initial?.value;
|
|
674
749
|
return relayLock(options, initial.value.relayScopeId, `controller-relay-finish:${input.workId}`, () => {
|
|
675
750
|
const current = readRelayRecord(options, input.workId);
|
|
676
751
|
if (!current || current.value.status !== 'dispatching') return current?.value;
|
|
677
|
-
const at = nowIso(options);
|
|
752
|
+
const at = typeof input.nowMs === 'number' ? new Date(input.nowMs).toISOString() : nowIso(options);
|
|
753
|
+
const nextFailureCount = current.value.consecutiveFailures + 1;
|
|
754
|
+
const recoveryBlocked = input.recovery === true && nextFailureCount >= current.value.maxFailures;
|
|
755
|
+
const recoveryDelayMs = Math.min(
|
|
756
|
+
MAX_STALLED_RECOVERY_BACKOFF_MS,
|
|
757
|
+
DEFAULT_STALLED_RECOVERY_BACKOFF_MS * 2 ** Math.max(0, nextFailureCount - 1),
|
|
758
|
+
);
|
|
678
759
|
const next: ControllerRoundRelayRecord = input.ok
|
|
679
760
|
? {
|
|
680
761
|
...current.value,
|
|
681
762
|
status: 'dispatched',
|
|
682
763
|
consecutiveFailures: 0,
|
|
683
764
|
lastError: undefined,
|
|
765
|
+
nextRecoveryAt: undefined,
|
|
684
766
|
blockedReason: undefined,
|
|
685
767
|
tabSettlementStatus: 'round_open',
|
|
686
768
|
tabSettlementAt: undefined,
|
|
@@ -690,20 +772,39 @@ export function finishControllerRoundRelayDispatch(
|
|
|
690
772
|
dispatchedAt: at,
|
|
691
773
|
updatedAt: at,
|
|
692
774
|
}
|
|
693
|
-
:
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
775
|
+
: input.recovery
|
|
776
|
+
? {
|
|
777
|
+
...current.value,
|
|
778
|
+
status: recoveryBlocked ? 'blocked' : 'dispatching',
|
|
779
|
+
consecutiveFailures: nextFailureCount,
|
|
780
|
+
lastError: bounded(input.error, 2_000) ?? 'CHATGPT_CONTROLLER_RELAY_RECOVERY_FAILED',
|
|
781
|
+
blockedReason: recoveryBlocked
|
|
782
|
+
? `consecutive_failures:${nextFailureCount}>=${current.value.maxFailures}`
|
|
783
|
+
: undefined,
|
|
784
|
+
nextRecoveryAt: recoveryBlocked
|
|
785
|
+
? undefined
|
|
786
|
+
: new Date(Date.parse(at) + recoveryDelayMs).toISOString(),
|
|
787
|
+
updatedAt: at,
|
|
788
|
+
}
|
|
789
|
+
: {
|
|
790
|
+
...current.value,
|
|
791
|
+
status: 'failed',
|
|
792
|
+
consecutiveFailures: nextFailureCount,
|
|
793
|
+
nextRecoveryAt: undefined,
|
|
794
|
+
lastError: bounded(input.error, 2_000) ?? 'CONTROLLER_RELAY_DISPATCH_FAILED',
|
|
795
|
+
updatedAt: at,
|
|
796
|
+
};
|
|
700
797
|
writeControlPlaneRecord(options.controllerHome, {
|
|
701
798
|
namespace: NAMESPACE,
|
|
702
799
|
scope: options.repoId,
|
|
703
800
|
key: input.workId,
|
|
704
801
|
schemaVersion: SCHEMA_VERSION,
|
|
705
802
|
value: next,
|
|
706
|
-
action: input.ok
|
|
803
|
+
action: input.ok
|
|
804
|
+
? 'controller_round_relay_dispatched'
|
|
805
|
+
: input.recovery
|
|
806
|
+
? recoveryBlocked ? 'controller_round_relay_recovery_blocked' : 'controller_round_relay_recovery_retry_scheduled'
|
|
807
|
+
: 'controller_round_relay_failed',
|
|
707
808
|
expectedRevision: current.revision,
|
|
708
809
|
});
|
|
709
810
|
return next;
|
|
@@ -724,7 +825,7 @@ export function acknowledgeControllerRoundClaim(
|
|
|
724
825
|
) return initial.value;
|
|
725
826
|
} else if (initial.value.status === 'blocked') {
|
|
726
827
|
if (!initial.value.blockedReason?.startsWith('repeated_state:')) return initial.value;
|
|
727
|
-
} else if (initial.value.status
|
|
828
|
+
} else if (!['dispatching', 'dispatched'].includes(initial.value.status)) return initial.value;
|
|
728
829
|
if (input.session.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_CLAIM_REQUIRED: ${input.workId}`);
|
|
729
830
|
|
|
730
831
|
return relayLock(options, initial.value.relayScopeId, `controller-relay-claim:${input.session.controllerId}`, () => {
|
|
@@ -834,7 +935,7 @@ export function acknowledgeControllerRoundClaim(
|
|
|
834
935
|
});
|
|
835
936
|
return rearmed;
|
|
836
937
|
}
|
|
837
|
-
if (current.value.status
|
|
938
|
+
if (!['dispatching', 'dispatched'].includes(current.value.status)) return current.value;
|
|
838
939
|
const owner = getControllerSession(options, input.workId);
|
|
839
940
|
if (!owner) throw new Error(`CONTROLLER_RELAY_ACTIVE_CLAIM_REQUIRED: ${input.workId}`);
|
|
840
941
|
if (
|
|
@@ -886,26 +987,36 @@ export function claimStalledControllerRoundRelays(
|
|
|
886
987
|
for (const candidate of latestRelayRecordsByScope(options)) {
|
|
887
988
|
if (claimed.length >= limit) break;
|
|
888
989
|
if (!['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(candidate.status)) continue;
|
|
889
|
-
const
|
|
890
|
-
if (
|
|
990
|
+
const scheduledRecoveryAtMs = candidate.nextRecoveryAt ? Date.parse(candidate.nextRecoveryAt) : Number.NaN;
|
|
991
|
+
if (Number.isFinite(scheduledRecoveryAtMs)) {
|
|
992
|
+
if (nowMs < scheduledRecoveryAtMs) continue;
|
|
993
|
+
} else {
|
|
994
|
+
const roundOpenedAtMs = Date.parse(candidate.claimedAt ?? candidate.dispatchedAt ?? candidate.updatedAt);
|
|
995
|
+
if (!Number.isFinite(roundOpenedAtMs) || nowMs - roundOpenedAtMs < graceMs) continue;
|
|
996
|
+
}
|
|
891
997
|
const requirement = requirementForRelay(options, candidate.requirementId);
|
|
892
998
|
if (requirement && !['planned', 'active'].includes(requirement.state)) continue;
|
|
893
999
|
const candidateWorks = relevantWork(options, candidate);
|
|
894
1000
|
const activeCandidateWorks = candidateWorks.filter((work) => !isTerminalWorkContractStatus(work.status));
|
|
895
1001
|
if (activeCandidateWorks.length === 0) continue;
|
|
896
|
-
if (activeCandidateWorks.some((work) => controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
|
|
1002
|
+
if (activeCandidateWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
|
|
897
1003
|
|
|
898
1004
|
const next = relayLock(options, candidate.relayScopeId, `controller-relay-recover:${candidate.originWorkId}`, () => {
|
|
899
1005
|
const latest = relayHistory(options, candidate.relayScopeId)[0];
|
|
900
1006
|
if (!latest || latest.originWorkId !== candidate.originWorkId || latest.updatedAt !== candidate.updatedAt || latest.status !== candidate.status) return undefined;
|
|
901
|
-
const
|
|
902
|
-
if (
|
|
1007
|
+
const latestRecoveryAtMs = latest.nextRecoveryAt ? Date.parse(latest.nextRecoveryAt) : Number.NaN;
|
|
1008
|
+
if (Number.isFinite(latestRecoveryAtMs)) {
|
|
1009
|
+
if (nowMs < latestRecoveryAtMs) return undefined;
|
|
1010
|
+
} else {
|
|
1011
|
+
const latestRoundOpenedAtMs = Date.parse(latest.claimedAt ?? latest.dispatchedAt ?? latest.updatedAt);
|
|
1012
|
+
if (!Number.isFinite(latestRoundOpenedAtMs) || nowMs - latestRoundOpenedAtMs < graceMs) return undefined;
|
|
1013
|
+
}
|
|
903
1014
|
const latestRequirement = requirementForRelay(options, latest.requirementId);
|
|
904
1015
|
if (latestRequirement && !['planned', 'active'].includes(latestRequirement.state)) return undefined;
|
|
905
1016
|
const works = relevantWork(options, latest);
|
|
906
1017
|
const activeWorks = works.filter((work) => !isTerminalWorkContractStatus(work.status));
|
|
907
1018
|
if (activeWorks.length === 0) return undefined;
|
|
908
|
-
if (activeWorks.some((work) => controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
|
|
1019
|
+
if (activeWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
|
|
909
1020
|
|
|
910
1021
|
const currentRecord = readRelayRecord(options, latest.originWorkId);
|
|
911
1022
|
if (!currentRecord || currentRecord.value.updatedAt !== latest.updatedAt || currentRecord.value.status !== latest.status) return undefined;
|
|
@@ -925,15 +1036,25 @@ export function claimStalledControllerRoundRelays(
|
|
|
925
1036
|
const at = new Date(nowMs).toISOString();
|
|
926
1037
|
const recovered: ControllerRoundRelayRecord = {
|
|
927
1038
|
...latest,
|
|
1039
|
+
// Re-dispatching an already dispatched/claimed round creates a new
|
|
1040
|
+
// controller epoch. Retrying an in-flight dispatch keeps its capability.
|
|
1041
|
+
authorityId: blockedReason
|
|
1042
|
+
? latest.authorityId
|
|
1043
|
+
: latest.status === 'dispatching' && latest.authorityId
|
|
1044
|
+
? latest.authorityId
|
|
1045
|
+
: newControllerRoundAuthorityId(),
|
|
928
1046
|
status: blockedReason ? 'blocked' : 'dispatching',
|
|
929
1047
|
stateFingerprint,
|
|
930
1048
|
roundCount,
|
|
931
1049
|
repeatedStateCount,
|
|
932
|
-
lastError: latest.
|
|
933
|
-
?
|
|
934
|
-
: latest.status === '
|
|
935
|
-
? '
|
|
936
|
-
: latest.status === '
|
|
1050
|
+
lastError: latest.nextRecoveryAt
|
|
1051
|
+
? latest.lastError
|
|
1052
|
+
: latest.status === 'pending_release'
|
|
1053
|
+
? 'CONTROLLER_RELAY_RELEASE_TRANSITION_INCOMPLETE'
|
|
1054
|
+
: latest.status === 'dispatching'
|
|
1055
|
+
? 'CONTROLLER_RELAY_DISPATCH_TRANSITION_INCOMPLETE'
|
|
1056
|
+
: latest.status === 'claimed' ? 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED' : 'CONTROLLER_RELAY_ROUND_UNCLOSED',
|
|
1057
|
+
nextRecoveryAt: undefined,
|
|
937
1058
|
claimedAt: undefined,
|
|
938
1059
|
...(blockedReason ? { blockedReason } : { blockedReason: undefined }),
|
|
939
1060
|
updatedAt: at,
|
|
@@ -957,6 +1078,7 @@ export function claimStalledControllerRoundRelays(
|
|
|
957
1078
|
export function buildControllerRoundRelayPrompt(
|
|
958
1079
|
options: ControllerRoundRelayStoreOptions,
|
|
959
1080
|
record: ControllerRoundRelayRecord,
|
|
1081
|
+
promptOptions: { exactOriginWork?: boolean } = {},
|
|
960
1082
|
): string {
|
|
961
1083
|
const requirement = requirementForRelay(options, record.requirementId);
|
|
962
1084
|
const relevantWorks = relevantWork(options, record);
|
|
@@ -980,8 +1102,13 @@ export function buildControllerRoundRelayPrompt(
|
|
|
980
1102
|
requirementLine,
|
|
981
1103
|
`Linked Work snapshot:\n${workLines}`,
|
|
982
1104
|
`Active Handoff snapshot:\n${handoffLines}`,
|
|
983
|
-
|
|
1105
|
+
promptOptions.exactOriginWork
|
|
1106
|
+
? `This is a Work-bound scheduled round. Claim and advance only origin Work ${record.originWorkId}. Do not select, start, delegate, or resume a sibling Work, create another schedule, or widen scope. If this Work cannot safely advance after one bounded diagnostic or repair attempt, record the exact evidence, submit wait or wait_for_user, release ownership, and end the round.`
|
|
1107
|
+
: `Previous relay origin Work: ${record.originWorkId}. Do not assume the next action must continue that Work; select, start, or claim the appropriate Work from the latest semantic state.`,
|
|
984
1108
|
`Mechanical relay budget: round=${record.roundCount}/${record.maxRounds}; repeated_state=${record.repeatedStateCount}/${record.maxRepeatedState}; consecutive_failures=${record.consecutiveFailures}/${record.maxFailures}.`,
|
|
1109
|
+
record.authorityId
|
|
1110
|
+
? `This round's durable controller authority is controller_authority_id=${record.authorityId}. Pass this exact controller_authority_id together with relay_scope_id=${record.relayScopeId} on controller_claim, continue, finalize, stop, and controller_release. The opaque authority survives MCP transport/Runtime rotation within this round and rotates before a successor round; never substitute another Work/conversation's authority.`
|
|
1111
|
+
: `This is a legacy relay record without a controller authority capability. Forge permits only the exact already-claimed MCP/controller epoch to finish it; a replacement transport must reopen/recover the round before mutating Work lifecycle.`,
|
|
985
1112
|
...(['CONTROLLER_RELAY_ROUND_UNCLOSED', 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED'].includes(record.lastError ?? '')
|
|
986
1113
|
? ['The previous ChatGPT round did not submit an explicit disposition before its liveness grace elapsed. Forge is only recovering liveness: reread durable state, make the semantic decision in ChatGPT, and close this round explicitly.']
|
|
987
1114
|
: []),
|