@gotcos/glasses-server 6.61.7 → 6.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/CHANGELOG.md +39 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +11 -4
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +4 -2
- package/server/index.ts +141 -17
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +22 -2
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +48 -7
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-job-ledger.ts +194 -0
- package/server/lib/fork-thread.ts +169 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +57 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +32 -2
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +74 -5
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +349 -26
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +155 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +56 -5
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +33 -7
|
@@ -121,13 +121,17 @@ import {
|
|
|
121
121
|
type BindingState,
|
|
122
122
|
type NativeBinding,
|
|
123
123
|
} from '../lib/agent-session-binding-store.js'
|
|
124
|
+
import { boundedContinueNote, CONTINUE_NOTE_HEADER, continueNoteAcknowledged, fallbackContinueNote } from '../lib/continue-plan.js'
|
|
124
125
|
import type { RegistryCheck, RegistryRejection, RegistryResult } from '../lib/agent-session-binding-registry.js'
|
|
125
126
|
import { recordCosSpawn, releaseCosSpawn } from '../lib/agent-session-ownership-store.js'
|
|
126
127
|
import { isValidNativeThreadId } from '../lib/native-thread-id.js'
|
|
128
|
+
import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
|
|
129
|
+
import { CLIENT_FORK_ID_RE, ForkJobLedger, type ForkJobRow } from '../lib/fork-job-ledger.js'
|
|
127
130
|
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
128
131
|
import {
|
|
129
132
|
CANCEL_QUEUE_HOLD_MS,
|
|
130
133
|
CLIENT_CANCEL_ID_RE,
|
|
134
|
+
DESK_RUN_EFFECT_COPY,
|
|
131
135
|
cancelRefusalCopy,
|
|
132
136
|
cancelTargetFor,
|
|
133
137
|
type CancelFacts,
|
|
@@ -257,6 +261,11 @@ export interface AttachedTurnRequest {
|
|
|
257
261
|
* simply never calls it.
|
|
258
262
|
*/
|
|
259
263
|
onSpawn: (pid: number) => boolean
|
|
264
|
+
/**
|
|
265
|
+
* 6.62.0 /qa (Q3): the client sent `X-COS-Continue-Note: 1`, i.e. it shows `continue_note`.
|
|
266
|
+
* Only then may a Cursor Continue run Run Everything; otherwise it keeps the 6.61 Ask posture.
|
|
267
|
+
*/
|
|
268
|
+
continueNoteAck?: boolean
|
|
260
269
|
/**
|
|
261
270
|
* 6.53.0: aborted by POST .../cancel. The adapter checks it before the spawn and
|
|
262
271
|
* before the prompt write, and stops the child's process group after. Optional so an
|
|
@@ -370,6 +379,29 @@ export interface AgentSessionBindingsDeps {
|
|
|
370
379
|
*/
|
|
371
380
|
deliverCodexLiveTurn?: (request: { provider: string; sessionId: string; prompt: string; foreignHolder: boolean }) => Promise<{ ok: boolean; reason: string; verifiedBy?: string | null; queuedId?: string | null }>
|
|
372
381
|
|
|
382
|
+
/**
|
|
383
|
+
* 6.62.0 (plan 3.6, W8): may a BUSY Codex thread the Codex app holds take a turn through
|
|
384
|
+
* that app's own queue right now? Production: `COS_CODEX_LIVE_QUEUE` is on AND the COS
|
|
385
|
+
* queue for the thread is empty (so a new turn never jumps one already waiting). Absent
|
|
386
|
+
* or anything but `true`: a busy holder refuses `native_thread_working`, as in 6.61.
|
|
387
|
+
*/
|
|
388
|
+
busyCodexHop?: (threadId: string) => boolean
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 6.62.0 (plan 3.12): what a Continue on this thread would run with, in 60 characters
|
|
392
|
+
* or less (`session-continue-facts.ts`). Added to the attachability verdict as
|
|
393
|
+
* `continue_note`. Optional; absent, null, slow (past `CONTINUE_NOTE_BUDGET_MS`) or a
|
|
394
|
+
* throw omits the field, and the verdict is unchanged.
|
|
395
|
+
*/
|
|
396
|
+
continueNote?: (provider: BindableProvider, threadId: string, options?: { noteAck: boolean }) => Promise<string | null> | string | null
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 6.62.0 /qa (W9): the note when `continueNote` misses its budget, throws, or answers
|
|
400
|
+
* nothing usable, so a Cursor Continue never runs Run Everything unannounced. Defaults to
|
|
401
|
+
* `fallbackContinueNote` with this process's environment.
|
|
402
|
+
*/
|
|
403
|
+
continueNoteFallback?: (provider: BindableProvider, options: { noteAck: boolean }) => string | null
|
|
404
|
+
|
|
373
405
|
/**
|
|
374
406
|
* The self-recursion ledger. Defaults to the real process-wide one.
|
|
375
407
|
*
|
|
@@ -406,7 +438,7 @@ export interface AgentSessionBindingsDeps {
|
|
|
406
438
|
* write a NEW session rather than the one asked for, which for a fork means the
|
|
407
439
|
* copy silently lands in the wrong project.
|
|
408
440
|
*/
|
|
409
|
-
resolveForkWorkspace?: (provider: BindableProvider, nativeThreadId: string) => string | null
|
|
441
|
+
resolveForkWorkspace?: (provider: BindableProvider, nativeThreadId: string) => string | null | Promise<string | null>
|
|
410
442
|
|
|
411
443
|
/**
|
|
412
444
|
* Where an opaque fork reference is exchanged for the thread it names.
|
|
@@ -417,6 +449,13 @@ export interface AgentSessionBindingsDeps {
|
|
|
417
449
|
*/
|
|
418
450
|
forkRefs?: ForkRefStore
|
|
419
451
|
|
|
452
|
+
/**
|
|
453
|
+
* 6.63.0: where a background fork (`clientForkId` in the body) records its outcome for
|
|
454
|
+
* `GET /agent-session-forks/:clientForkId`. Defaults to an in-memory ledger per router;
|
|
455
|
+
* the server wires the durable one (`<data>/fork-jobs.json`).
|
|
456
|
+
*/
|
|
457
|
+
forkJobs?: ForkJobLedger
|
|
458
|
+
|
|
420
459
|
/** Lease TTL for a new binding. */
|
|
421
460
|
attachTtlMs?: number
|
|
422
461
|
/** Upper bound on one prompt. A prompt is not a payload. */
|
|
@@ -459,7 +498,7 @@ export interface CancelDeps {
|
|
|
459
498
|
* have reached) the open session. Writes the halt marker, and re-arms it once when that
|
|
460
499
|
* turn's own UserPromptSubmit deletes it (`haltHandedOffTurn`). False: not armed.
|
|
461
500
|
*/
|
|
462
|
-
haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean }) => boolean
|
|
501
|
+
haltHandedOffTurn?: (sessionId: string, marker: { at: number; clientCancelId: string }, turn: { prompt: string; sentAt: number; unverified?: boolean; verbatim?: boolean; deferMarker?: boolean }) => boolean
|
|
463
502
|
/** Append to `data/session-cancel.jsonl`. */
|
|
464
503
|
ledger?: (row: SessionCancelLedgerRow) => void
|
|
465
504
|
}
|
|
@@ -597,6 +636,13 @@ export type WriteRefusal =
|
|
|
597
636
|
| 'fork_failed'
|
|
598
637
|
| 'fork_source_mutated'
|
|
599
638
|
| 'fork_orphan_possible'
|
|
639
|
+
// 6.62.1 (2026-10-05): the copy exists and is named, but the provider refused its
|
|
640
|
+
// first turn. Each says WHY, because the next step differs: a smaller source, a
|
|
641
|
+
// reset, or a sign-in. None is retryable: a retry makes another full copy.
|
|
642
|
+
| 'fork_turn_failed'
|
|
643
|
+
| 'fork_context_too_long'
|
|
644
|
+
| 'fork_provider_limit'
|
|
645
|
+
| 'fork_provider_auth'
|
|
600
646
|
|
|
601
647
|
/**
|
|
602
648
|
* Footer copy for the refusals that are not occupancy reasons.
|
|
@@ -680,8 +726,18 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
|
|
|
680
726
|
// touches it.
|
|
681
727
|
fork_source_mutated:
|
|
682
728
|
'The original thread changed while COS was copying it. Open the original on your Mac and check it before doing anything else.',
|
|
729
|
+
// 6.62.1: no "untouched" here. This outcome is reached exactly when COS could not
|
|
730
|
+
// read the copy's result, so it cannot vouch for anything after the spawn either.
|
|
683
731
|
fork_orphan_possible:
|
|
684
|
-
'COS
|
|
732
|
+
'COS did not get an answer from the copy it was making. A copy may exist on your Mac; check your sessions before forking again.',
|
|
733
|
+
fork_turn_failed:
|
|
734
|
+
'COS made the copy, but the assistant did not answer in it. Open the copy to see why before trying again.',
|
|
735
|
+
fork_context_too_long:
|
|
736
|
+
'This conversation is too long to continue in a copy: the assistant refused it as too long. Start a new session with the task instead.',
|
|
737
|
+
fork_provider_limit:
|
|
738
|
+
'The assistant hit its usage limit, so the copy got no answer. Try again after the limit resets, or start a new session with another assistant.',
|
|
739
|
+
fork_provider_auth:
|
|
740
|
+
'The assistant needs you to sign in again on this Mac before COS can copy this conversation.',
|
|
685
741
|
}
|
|
686
742
|
|
|
687
743
|
export function writeReasonCopy(reason: WriteRefusal): string {
|
|
@@ -814,7 +870,7 @@ export function projectAttachability(verdict: Occupancy): AttachabilityBody {
|
|
|
814
870
|
const owners = Array.isArray(verdict?.owners) ? verdict.owners : null
|
|
815
871
|
// Strictly `=== true`. An idleHolder of 1, 'yes', or {} is a malformed verdict,
|
|
816
872
|
// and a malformed verdict must not buy an exemption.
|
|
817
|
-
const foreignOwnerDeclared = verdict?.idleHolder === true
|
|
873
|
+
const foreignOwnerDeclared = verdict?.idleHolder === true || verdict?.busyHolder === true
|
|
818
874
|
const sound =
|
|
819
875
|
verdict?.attachable === true &&
|
|
820
876
|
verdict.reason === null &&
|
|
@@ -1412,9 +1468,23 @@ export class ForkRefStore {
|
|
|
1412
1468
|
export type ForkOutcome =
|
|
1413
1469
|
| { kind: 'created'; newNativeThreadId: string; integrity: 'verified_unchanged' | 'unverified' }
|
|
1414
1470
|
| { kind: 'mutated' }
|
|
1415
|
-
|
|
1471
|
+
// 6.62.1: the copy is named but its first turn failed; `cause` is the provider's own
|
|
1472
|
+
// reason when its error event said one.
|
|
1473
|
+
| { kind: 'turn_failed'; newNativeThreadId: string; cause: ForkProviderFailure | null }
|
|
1474
|
+
| { kind: 'orphan_possible'; cause?: ForkProviderFailure | null }
|
|
1416
1475
|
| { kind: 'failed' }
|
|
1417
1476
|
|
|
1477
|
+
const forkProviderFailure = (detail: unknown): ForkProviderFailure | null =>
|
|
1478
|
+
typeof detail === 'string' && (FORK_PROVIDER_FAILURES as readonly string[]).includes(detail) ? detail as ForkProviderFailure : null
|
|
1479
|
+
|
|
1480
|
+
/** The refusal reason for a provider-stated cause, or the generic one. */
|
|
1481
|
+
export function forkFailureReason(cause: ForkProviderFailure | null | undefined, generic: WriteRefusal): WriteRefusal {
|
|
1482
|
+
if (cause === 'context_too_long') return 'fork_context_too_long'
|
|
1483
|
+
if (cause === 'provider_limit') return 'fork_provider_limit'
|
|
1484
|
+
if (cause === 'provider_auth') return 'fork_provider_auth'
|
|
1485
|
+
return generic
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1418
1488
|
/**
|
|
1419
1489
|
* Read a fork result without believing anything it did not say.
|
|
1420
1490
|
*
|
|
@@ -1427,7 +1497,7 @@ export type ForkOutcome =
|
|
|
1427
1497
|
*/
|
|
1428
1498
|
export function classifyFork(result: unknown, sourceNativeThreadId: string): ForkOutcome {
|
|
1429
1499
|
if (!result || typeof result !== 'object' || Array.isArray(result)) return { kind: 'orphan_possible' }
|
|
1430
|
-
const { ok, newNativeThreadId, sourceIntegrity, forkState, reason } = result as Record<string, unknown>
|
|
1500
|
+
const { ok, newNativeThreadId, sourceIntegrity, forkState, reason, detail } = result as Record<string, unknown>
|
|
1431
1501
|
|
|
1432
1502
|
if (ok === true) {
|
|
1433
1503
|
// The single invariant this route re-checks itself rather than inheriting.
|
|
@@ -1449,11 +1519,35 @@ export function classifyFork(result: unknown, sourceNativeThreadId: string): For
|
|
|
1449
1519
|
if (reason === 'source_thread_mutated' || sourceIntegrity === 'mutated') return { kind: 'mutated' }
|
|
1450
1520
|
// Only an explicit "no child was created" earns the clean failure.
|
|
1451
1521
|
if (forkState === 'none') return { kind: 'failed' }
|
|
1522
|
+
// 6.62.1: a named copy is not an orphan. Same id rules as success: valid, and
|
|
1523
|
+
// never the source (that would be an append reported as a copy).
|
|
1524
|
+
if (isValidNativeThreadId(newNativeThreadId) && newNativeThreadId !== sourceNativeThreadId) {
|
|
1525
|
+
return { kind: 'turn_failed', newNativeThreadId, cause: forkProviderFailure(detail) }
|
|
1526
|
+
}
|
|
1527
|
+
return { kind: 'orphan_possible', cause: forkProviderFailure(detail) }
|
|
1452
1528
|
}
|
|
1453
1529
|
return { kind: 'orphan_possible' }
|
|
1454
1530
|
}
|
|
1455
1531
|
|
|
1456
1532
|
export const FORKED_COPY = 'Copied into a new thread. Your original is untouched.'
|
|
1533
|
+
/** 6.63.0: the 202 for a background fork. The outcome is read from the status route. */
|
|
1534
|
+
export const FORK_RUNNING_COPY = 'COS is making the copy and sending your instruction. This can take several minutes.'
|
|
1535
|
+
export const FORK_JOB_UNKNOWN_COPY = 'This server has no record of that fork. Look for the copy in Sessions before forking again.'
|
|
1536
|
+
|
|
1537
|
+
/** What a background fork's id answers right now: running, or the recorded outcome. */
|
|
1538
|
+
export function projectForkJob(row: ForkJobRow): { status: number; body: Record<string, unknown> } {
|
|
1539
|
+
if (row.state === 'running') {
|
|
1540
|
+
return { status: 200, body: { state: 'running', clientForkId: row.clientForkId, acceptedAt: row.acceptedAt, reasonCopy: FORK_RUNNING_COPY } }
|
|
1541
|
+
}
|
|
1542
|
+
// `status` is the ORIGINAL answer's code, surfaced under its own name: a refused fork
|
|
1543
|
+
// read back correctly is a successful read.
|
|
1544
|
+
return {
|
|
1545
|
+
status: 200,
|
|
1546
|
+
body: { ...(row.result ?? {}), state: 'done', clientForkId: row.clientForkId, recordedStatus: row.status ?? null, polled: true },
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
/** 6.62.1: `unverified` must never be rendered as confirmed untouched (see the success body). */
|
|
1550
|
+
export const FORKED_COPY_UNVERIFIED = 'Copied into a new thread. COS could not re-read your original afterwards to confirm it is unchanged.'
|
|
1457
1551
|
|
|
1458
1552
|
/**
|
|
1459
1553
|
* Both fingerprints present, bounded, and strings.
|
|
@@ -1500,6 +1594,12 @@ export const TURN_SENT_CODEX_QUEUE_COPY = 'Queued in the Codex app. It runs when
|
|
|
1500
1594
|
*/
|
|
1501
1595
|
export const LIVE_VERIFY_BUDGET_MS = PEER_VERIFY_TIMEOUT_MS
|
|
1502
1596
|
|
|
1597
|
+
/**
|
|
1598
|
+
* 6.62.0: how long the attachability verdict waits for its `continue_note`. The verdict is
|
|
1599
|
+
* a menu-open read; a cold Codex posture scan or a busy Cursor database must not hold it.
|
|
1600
|
+
*/
|
|
1601
|
+
export const CONTINUE_NOTE_BUDGET_MS = 1_500
|
|
1602
|
+
|
|
1503
1603
|
/**
|
|
1504
1604
|
* The 202 copy. Says QUEUED and not sent, because at this instant the provider has
|
|
1505
1605
|
* not been spawned — claiming otherwise would be the same silent-success lie the
|
|
@@ -1572,6 +1672,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1572
1672
|
// One per router. Injectable so the follow-on (attach accepting a `forkRef`)
|
|
1573
1673
|
// shares this instance rather than standing up a second, disconnected one.
|
|
1574
1674
|
const forkRefs = deps?.forkRefs instanceof ForkRefStore ? deps.forkRefs : new ForkRefStore()
|
|
1675
|
+
const forkJobs = deps?.forkJobs instanceof ForkJobLedger ? deps.forkJobs : new ForkJobLedger(randomUUID())
|
|
1575
1676
|
const attachTtlMs =
|
|
1576
1677
|
Number.isFinite(deps?.attachTtlMs) && (deps.attachTtlMs as number) > 0
|
|
1577
1678
|
? (deps.attachTtlMs as number)
|
|
@@ -1623,6 +1724,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1623
1724
|
}
|
|
1624
1725
|
return {
|
|
1625
1726
|
provider,
|
|
1727
|
+
threadId,
|
|
1626
1728
|
cosTurnInFlight,
|
|
1627
1729
|
runningOutsideCos,
|
|
1628
1730
|
hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
|
|
@@ -1660,7 +1762,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1660
1762
|
provider: string,
|
|
1661
1763
|
threadId: string,
|
|
1662
1764
|
reached: 'none' | 'reached' | 'maybe',
|
|
1663
|
-
turn: { prompt: string; sentAt: number },
|
|
1765
|
+
turn: { prompt: string; sentAt: number; busy?: boolean },
|
|
1664
1766
|
): void => {
|
|
1665
1767
|
const latch = entry.latched
|
|
1666
1768
|
if (latch === null) return
|
|
@@ -1673,6 +1775,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1673
1775
|
if (reached !== 'none') {
|
|
1674
1776
|
target = cancelTargetFor({
|
|
1675
1777
|
provider,
|
|
1778
|
+
threadId,
|
|
1676
1779
|
cosTurnInFlight: false,
|
|
1677
1780
|
runningOutsideCos: true,
|
|
1678
1781
|
hooksEnabled: cancelProbe(() => cancelDeps.hooksEnabled?.() === true, false),
|
|
@@ -1685,6 +1788,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1685
1788
|
prompt: turn.prompt,
|
|
1686
1789
|
sentAt: turn.sentAt,
|
|
1687
1790
|
unverified: reached === 'maybe',
|
|
1791
|
+
// 6.62.0 (plan 3.6): a Codex turn went into the app's own queue, which hands its
|
|
1792
|
+
// text to the session's UserPromptSubmit as is: matched verbatim, no peer unwrap.
|
|
1793
|
+
...(provider === 'codex' ? { verbatim: true } : {}),
|
|
1794
|
+
// 6.62.0 /qa (W3): on a BUSY hop the session is still running Miles's OWN desk turn.
|
|
1795
|
+
// A marker written now would stop that turn at its next tool, so only the re-arm is
|
|
1796
|
+
// registered: the marker lands on the delivered turn's own UserPromptSubmit.
|
|
1797
|
+
...(turn.busy === true ? { deferMarker: true } : {}),
|
|
1688
1798
|
}) === true, false)
|
|
1689
1799
|
if (armed) {
|
|
1690
1800
|
const settledPermissions = cancelProbe(() => cancelDeps.settlePermissions?.(threadId) ?? 0, 0)
|
|
@@ -1692,6 +1802,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1692
1802
|
cancelled: true,
|
|
1693
1803
|
target,
|
|
1694
1804
|
effective: 'next_tool_call',
|
|
1805
|
+
effectCopy: DESK_RUN_EFFECT_COPY,
|
|
1695
1806
|
...common,
|
|
1696
1807
|
settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
|
|
1697
1808
|
}
|
|
@@ -1743,8 +1854,29 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1743
1854
|
return { attachable: false, owners: [], reason: 'probe_failed' }
|
|
1744
1855
|
}
|
|
1745
1856
|
}
|
|
1857
|
+
/**
|
|
1858
|
+
* 6.62.0 (plan 3.6, W8): a WORKING Codex holder that is not ours, on a thread the hop may
|
|
1859
|
+
* take, reads attachable through a declared `busyHolder`. Shared by the probe, attach and
|
|
1860
|
+
* turns, so all three agree. Anything less than every condition keeps the verdict as is.
|
|
1861
|
+
*/
|
|
1862
|
+
const withBusyCodexHop = (provider: string, threadId: string, detected: Occupancy): Occupancy => {
|
|
1863
|
+
if (provider !== 'codex' || detected.attachable || detected.reason !== 'native_thread_working') return detected
|
|
1864
|
+
if (typeof deps.deliverCodexLiveTurn !== 'function' || typeof deps.busyCodexHop !== 'function') return detected
|
|
1865
|
+
const owners = Array.isArray(detected.owners) ? detected.owners : []
|
|
1866
|
+
if (!owners.some(owner => owner?.selfOwned !== true && owner?.source === 'codex-writer-lock')) return detected
|
|
1867
|
+
let allowed = false
|
|
1868
|
+
try {
|
|
1869
|
+
allowed = deps.busyCodexHop(threadId) === true
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
console.error(`[agent-session-bindings] busy codex hop check threw: ${error instanceof Error ? error.message : error}`)
|
|
1872
|
+
allowed = false
|
|
1873
|
+
}
|
|
1874
|
+
return allowed ? { attachable: true, owners, reason: null, busyHolder: true } : detected
|
|
1875
|
+
}
|
|
1876
|
+
const routeOccupancy = (provider: string, threadId: string): Occupancy =>
|
|
1877
|
+
withBusyCodexHop(provider, threadId, detectOccupancy(provider, threadId))
|
|
1746
1878
|
const runOccupancy = (provider: string, threadId: string): AttachabilityBody =>
|
|
1747
|
-
projectAttachability(
|
|
1879
|
+
projectAttachability(routeOccupancy(provider, threadId))
|
|
1748
1880
|
|
|
1749
1881
|
const refuseAttach = (res: Response, reason: WriteRefusal): void => {
|
|
1750
1882
|
res.status(refusalStatus(reason)).json({
|
|
@@ -1853,7 +1985,46 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1853
1985
|
res.json({ released: true, target, provider: outcome.row.provider })
|
|
1854
1986
|
})
|
|
1855
1987
|
|
|
1856
|
-
|
|
1988
|
+
/**
|
|
1989
|
+
* 6.62.0 (plan 3.12): `continue_note` for the verdict, bounded in time and length. 6.62.0
|
|
1990
|
+
* /qa (W9): a read that misses the budget, throws, or answers nothing usable gets the
|
|
1991
|
+
* provider's fixed note instead, never no note (Claude has none either way).
|
|
1992
|
+
*/
|
|
1993
|
+
const continueNoteFor = async (provider: string, threadId: string, noteAck: boolean): Promise<{ continue_note?: string }> => {
|
|
1994
|
+
const read = deps.continueNote
|
|
1995
|
+
if (typeof read !== 'function' || !isBindableProvider(provider) || !isValidNativeThreadId(threadId)) return {}
|
|
1996
|
+
const fallback = (): { continue_note?: string } => {
|
|
1997
|
+
let note: string | null = null
|
|
1998
|
+
try {
|
|
1999
|
+
note = deps.continueNoteFallback
|
|
2000
|
+
? deps.continueNoteFallback(provider, { noteAck })
|
|
2001
|
+
: fallbackContinueNote(provider, { noteAck })
|
|
2002
|
+
} catch {
|
|
2003
|
+
note = null
|
|
2004
|
+
}
|
|
2005
|
+
const bounded = boundedContinueNote(note)
|
|
2006
|
+
return bounded ? { continue_note: bounded } : {}
|
|
2007
|
+
}
|
|
2008
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
2009
|
+
let missed = false
|
|
2010
|
+
try {
|
|
2011
|
+
const note = await Promise.race([
|
|
2012
|
+
Promise.resolve(read(provider, threadId, { noteAck })),
|
|
2013
|
+
new Promise<null>(resolve => { timer = setTimeout(() => { missed = true; resolve(null) }, CONTINUE_NOTE_BUDGET_MS) }),
|
|
2014
|
+
])
|
|
2015
|
+
const bounded = boundedContinueNote(note)
|
|
2016
|
+
if (bounded) return { continue_note: bounded }
|
|
2017
|
+
if (missed) console.warn(`[agent-session-bindings] continue note missed its ${CONTINUE_NOTE_BUDGET_MS} ms budget provider=${provider}: fixed note sent`)
|
|
2018
|
+
return fallback()
|
|
2019
|
+
} catch (error) {
|
|
2020
|
+
console.error(`[agent-session-bindings] continue note failed: ${error instanceof Error ? error.message : error}`)
|
|
2021
|
+
return fallback()
|
|
2022
|
+
} finally {
|
|
2023
|
+
if (timer) clearTimeout(timer)
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
router.get('/agent-sessions/:provider/:threadId/attachability', async (req, res) => {
|
|
1857
2028
|
// An occupancy verdict is a liveness answer with a lifetime of roughly now.
|
|
1858
2029
|
// A cached `attachable: true` is indistinguishable from a stale one, which is
|
|
1859
2030
|
// the whole failure this feature exists to prevent.
|
|
@@ -1899,7 +2070,17 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1899
2070
|
// truncated-id hole opened. The tests pin the ordering behaviorally instead:
|
|
1900
2071
|
// probes that throw on every call still return `unsupported_provider` /
|
|
1901
2072
|
// `invalid_thread_id`, which is only possible if nothing was probed.
|
|
1902
|
-
|
|
2073
|
+
// 6.62.0 /qa (Q2): the busy Codex hop makes the verdict attachable, but a desk run IS
|
|
2074
|
+
// working on that thread, so the cancel row reads the RAW reason. Built from the verdict's
|
|
2075
|
+
// reason, the lens hid Cancel run on every busy Codex Desktop thread.
|
|
2076
|
+
const detected = routeOccupancy(provider, threadId)
|
|
2077
|
+
const verdictBody = projectAttachability(detected)
|
|
2078
|
+
const cancelReason = detected.busyHolder === true ? 'native_thread_working' : verdictBody.reason
|
|
2079
|
+
res.json({
|
|
2080
|
+
...verdictBody,
|
|
2081
|
+
cancel: cancelFieldFor(provider, threadId, cancelReason),
|
|
2082
|
+
...(await continueNoteFor(provider, threadId, continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]))),
|
|
2083
|
+
})
|
|
1903
2084
|
})
|
|
1904
2085
|
|
|
1905
2086
|
// ------------------------------------------------------------------ cancel
|
|
@@ -1989,7 +2170,11 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
1989
2170
|
activeEntry.controller.abort()
|
|
1990
2171
|
// Noted as what the run becomes if the hop lands it: a desk run (Claude) or one only
|
|
1991
2172
|
// its app can stop. Either way only the engine's own end discounts its open turn.
|
|
1992
|
-
|
|
2173
|
+
// 6.62.0: a landed Codex or Cursor hop is a desk run COS can stop when that engine's
|
|
2174
|
+
// hooks can (cancelTargetFor's own rule); Claude keeps its 6.53.3 answer unchanged.
|
|
2175
|
+
const landedAs = provider === 'claude' ? 'desk_run'
|
|
2176
|
+
: cancelTargetFor({ ...cancelFactsFor(provider, threadId, null), cosTurnInFlight: false, runningOutsideCos: true }) === 'desk_run' ? 'desk_run' : 'unsupported'
|
|
2177
|
+
cancelProbe(() => cancelDeps.noteCancelled?.(provider, threadId, now, landedAs), undefined)
|
|
1993
2178
|
}
|
|
1994
2179
|
activeEntry.latched.replayKeys.push(replayKey)
|
|
1995
2180
|
return answer(202, 'cos_turn', 'accepted', {
|
|
@@ -2034,6 +2219,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2034
2219
|
cancelled: true,
|
|
2035
2220
|
target,
|
|
2036
2221
|
effective: 'next_tool_call',
|
|
2222
|
+
// 6.62.0 (plan 3.8): the words for what the tap did, the same for every engine.
|
|
2223
|
+
effectCopy: DESK_RUN_EFFECT_COPY,
|
|
2037
2224
|
queuedHeld: queuedHeld(),
|
|
2038
2225
|
queuedHoldMs: CANCEL_QUEUE_HOLD_MS,
|
|
2039
2226
|
settledPermissions: Number.isInteger(settledPermissions) && settledPermissions > 0 ? settledPermissions : 0,
|
|
@@ -2281,10 +2468,23 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2281
2468
|
|
|
2282
2469
|
/** The per-source serialisation claim, released in the finally. */
|
|
2283
2470
|
let claimedForkKey: string | null = null
|
|
2471
|
+
/**
|
|
2472
|
+
* 6.63.0: set once a background fork has answered 202. From then on the ledger is the
|
|
2473
|
+
* ONLY channel for the outcome, exactly like a queued turn: `answer` settles it there.
|
|
2474
|
+
*/
|
|
2475
|
+
let backgroundForkId: string | null = null
|
|
2284
2476
|
|
|
2285
|
-
const
|
|
2477
|
+
const answer = (status: number, payload: Record<string, unknown>): void => {
|
|
2478
|
+
if (backgroundForkId !== null) {
|
|
2479
|
+
forkJobs.settle(backgroundForkId, status, payload, readNow() ?? Date.now())
|
|
2480
|
+
return
|
|
2481
|
+
}
|
|
2286
2482
|
if (res.headersSent) return
|
|
2287
|
-
res.status(
|
|
2483
|
+
res.status(status).json(payload)
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
const refuseFork = (reason: WriteRefusal, extra: Record<string, unknown> = {}): void => {
|
|
2487
|
+
answer(refusalStatus(reason), {
|
|
2288
2488
|
forked: false,
|
|
2289
2489
|
forkRef: null,
|
|
2290
2490
|
sourceIntegrity: null,
|
|
@@ -2298,6 +2498,16 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2298
2498
|
})
|
|
2299
2499
|
}
|
|
2300
2500
|
|
|
2501
|
+
/** Answers a known background fork id with its job, never a second spawn. */
|
|
2502
|
+
const replayForkJob = (row: ForkJobRow): void => {
|
|
2503
|
+
const projected = projectForkJob(row)
|
|
2504
|
+
res.status(row.state === 'running' ? 202 : projected.status).json({
|
|
2505
|
+
...projected.body,
|
|
2506
|
+
accepted: row.state === 'running' ? true : undefined,
|
|
2507
|
+
replayed: true,
|
|
2508
|
+
})
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2301
2511
|
try {
|
|
2302
2512
|
const fork = deps.forkThread
|
|
2303
2513
|
if (typeof fork !== 'function') return refuseFork('fork_unwired')
|
|
@@ -2315,6 +2525,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2315
2525
|
if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
|
|
2316
2526
|
return refuseFork('invalid_request')
|
|
2317
2527
|
}
|
|
2528
|
+
// 6.63.0: optional. With it, the fork answers 202 once every gate has passed and runs
|
|
2529
|
+
// in the background; without it, the route behaves exactly as before.
|
|
2530
|
+
const clientForkId = body.clientForkId
|
|
2531
|
+
if (clientForkId !== undefined && (typeof clientForkId !== 'string' || !CLIENT_FORK_ID_RE.test(clientForkId))) {
|
|
2532
|
+
return refuseFork('invalid_request')
|
|
2533
|
+
}
|
|
2318
2534
|
|
|
2319
2535
|
const providerParam = String(req.params.provider ?? '')
|
|
2320
2536
|
const threadIdParam = String(req.params.threadId ?? '')
|
|
@@ -2324,6 +2540,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2324
2540
|
if (!isForkableProvider(providerParam)) return refuseFork('fork_unsupported_provider')
|
|
2325
2541
|
if (!isValidNativeThreadId(threadIdParam)) return refuseFork('fork_invalid_thread_id')
|
|
2326
2542
|
|
|
2543
|
+
// A repeat of a known id replays its job and never spawns: a client that lost the
|
|
2544
|
+
// 202 (or restarted) must be able to resend without making a second copy.
|
|
2545
|
+
const forkSource = opaqueRevision(targetKey(providerParam, threadIdParam))
|
|
2546
|
+
if (typeof clientForkId === 'string') {
|
|
2547
|
+
const known = forkJobs.get(clientForkId)
|
|
2548
|
+
if (known) return known.source === forkSource ? replayForkJob(known) : refuseFork('invalid_request')
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2327
2551
|
const now = readNow()
|
|
2328
2552
|
if (now === null) return refuseFork('fork_failed')
|
|
2329
2553
|
|
|
@@ -2331,7 +2555,9 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2331
2555
|
if (typeof resolveWorkspace !== 'function') return refuseFork('fork_workspace_unresolvable')
|
|
2332
2556
|
let cwd: string | null = null
|
|
2333
2557
|
try {
|
|
2334
|
-
|
|
2558
|
+
// 6.62.0: may answer later (a Cursor IDE composer's folder is read from Cursor's own
|
|
2559
|
+
// store). Still before the claim below, which stays the last synchronous step.
|
|
2560
|
+
cwd = await resolveWorkspace(providerParam, threadIdParam)
|
|
2335
2561
|
} catch (error) {
|
|
2336
2562
|
console.error(`[agent-session-bindings] fork workspace resolve threw: ${error instanceof Error ? error.message : error}`)
|
|
2337
2563
|
cwd = null
|
|
@@ -2350,9 +2576,36 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2350
2576
|
// continuation or be blocked by one — `targetKey` is length-prefixed and
|
|
2351
2577
|
// therefore unambiguous, and this prefix cannot collide with one.
|
|
2352
2578
|
const forkKey = `fork:${targetKey(providerParam, threadIdParam)}`
|
|
2353
|
-
if (!guard.tryClaim(forkKey, forkKey))
|
|
2579
|
+
if (!guard.tryClaim(forkKey, forkKey)) {
|
|
2580
|
+
// The same id arriving twice while the first is still resolving its workspace: the
|
|
2581
|
+
// first holds the claim and has begun the job by now, so replay it.
|
|
2582
|
+
const raced = typeof clientForkId === 'string' ? forkJobs.get(clientForkId) : null
|
|
2583
|
+
if (raced && raced.source === forkSource) return replayForkJob(raced)
|
|
2584
|
+
return refuseFork('fork_in_progress')
|
|
2585
|
+
}
|
|
2354
2586
|
claimedForkKey = forkKey
|
|
2355
2587
|
|
|
2588
|
+
if (typeof clientForkId === 'string') {
|
|
2589
|
+
// Recorded BEFORE the spawn and before the 202: a job the ledger cannot hold would
|
|
2590
|
+
// have an outcome nobody can read, so it does not start.
|
|
2591
|
+
const begun = forkJobs.begin({ clientForkId, source: forkSource, provider: providerParam }, now)
|
|
2592
|
+
if (begun.kind === 'existing') return replayForkJob(begun.row)
|
|
2593
|
+
if (begun.kind !== 'started') return refuseFork(begun.kind === 'conflict' ? 'invalid_request' : 'fork_failed')
|
|
2594
|
+
res.status(202).json({
|
|
2595
|
+
accepted: true,
|
|
2596
|
+
state: 'running',
|
|
2597
|
+
clientForkId,
|
|
2598
|
+
forked: null,
|
|
2599
|
+
forkRef: null,
|
|
2600
|
+
orphanPossible: false,
|
|
2601
|
+
retryable: false,
|
|
2602
|
+
reason: null,
|
|
2603
|
+
reasonCopy: FORK_RUNNING_COPY,
|
|
2604
|
+
})
|
|
2605
|
+
backgroundForkId = clientForkId
|
|
2606
|
+
console.log(`[agent-session-bindings] fork accepted provider=${providerParam} background=true`)
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2356
2609
|
let raw: unknown
|
|
2357
2610
|
try {
|
|
2358
2611
|
raw = await fork({
|
|
@@ -2371,14 +2624,32 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2371
2624
|
}
|
|
2372
2625
|
|
|
2373
2626
|
const outcome = classifyFork(raw, threadIdParam)
|
|
2627
|
+
{
|
|
2628
|
+
// 6.62.1: one line per attempt. On 2026-10-05 a fork's real cause existed only
|
|
2629
|
+
// in the copy's transcript; nothing reached the server log. Enums and numbers
|
|
2630
|
+
// only: no prompt, no provider text, no thread id.
|
|
2631
|
+
const r = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
|
2632
|
+
console.log(`[agent-session-bindings] fork outcome provider=${providerParam} outcome=${outcome.kind} cause=${forkProviderFailure(r.detail) ?? 'none'} exit=${typeof r.exitCode === 'number' && Number.isFinite(r.exitCode) ? r.exitCode : 'none'} ms=${typeof r.durationMs === 'number' && Number.isFinite(r.durationMs) ? r.durationMs : 'unknown'}`)
|
|
2633
|
+
}
|
|
2374
2634
|
|
|
2375
2635
|
if (outcome.kind === 'mutated') {
|
|
2376
2636
|
// The original moved. Loudest outcome in the feature, and not retryable:
|
|
2377
2637
|
// the user needs to look at their own thread before anything else touches it.
|
|
2378
2638
|
return refuseFork('fork_source_mutated', { orphanPossible: true, retryable: false })
|
|
2379
2639
|
}
|
|
2640
|
+
if (outcome.kind === 'turn_failed') {
|
|
2641
|
+
// The copy exists and Control can open it (the helper resolves the digest to the
|
|
2642
|
+
// local transcript). Not an orphan, and not retryable: another try copies again.
|
|
2643
|
+
const copyRef = forkRefs.remember(providerParam, outcome.newNativeThreadId, now)
|
|
2644
|
+
return refuseFork(forkFailureReason(outcome.cause, 'fork_turn_failed'), {
|
|
2645
|
+
forkRef: copyRef, turnFailed: true, failureClass: outcome.cause, orphanPossible: false, retryable: false,
|
|
2646
|
+
})
|
|
2647
|
+
}
|
|
2380
2648
|
if (outcome.kind === 'orphan_possible') {
|
|
2381
|
-
|
|
2649
|
+
const cause = outcome.cause ?? null
|
|
2650
|
+
return refuseFork(forkFailureReason(cause, 'fork_orphan_possible'), {
|
|
2651
|
+
orphanPossible: true, failureClass: cause, retryable: cause === null,
|
|
2652
|
+
})
|
|
2382
2653
|
}
|
|
2383
2654
|
if (outcome.kind === 'failed') return refuseFork('fork_failed')
|
|
2384
2655
|
|
|
@@ -2386,10 +2657,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2386
2657
|
// a fresh fork exactly as for an existing thread.
|
|
2387
2658
|
const forkRef = forkRefs.remember(providerParam, outcome.newNativeThreadId, now)
|
|
2388
2659
|
|
|
2389
|
-
|
|
2660
|
+
answer(201, {
|
|
2390
2661
|
forked: true,
|
|
2391
2662
|
reason: null,
|
|
2392
|
-
reasonCopy: FORKED_COPY,
|
|
2663
|
+
reasonCopy: outcome.integrity === 'verified_unchanged' ? FORKED_COPY : FORKED_COPY_UNVERIFIED,
|
|
2393
2664
|
forkRef,
|
|
2394
2665
|
// Reported, never assumed. `unverified` means COS could not read the
|
|
2395
2666
|
// original at both ends — which is not the same as, and must never be
|
|
@@ -2404,7 +2675,39 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2404
2675
|
refuseFork('fork_orphan_possible', { orphanPossible: true })
|
|
2405
2676
|
} finally {
|
|
2406
2677
|
if (claimedForkKey !== null) guard.release(claimedForkKey, claimedForkKey)
|
|
2678
|
+
// A background job must never be left `running` by a path that forgot to answer:
|
|
2679
|
+
// its caller would poll a fork that can no longer finish. Cautious outcome only.
|
|
2680
|
+
if (backgroundForkId !== null && forkJobs.get(backgroundForkId)?.state === 'running') {
|
|
2681
|
+
console.error('[agent-session-bindings] background fork ended without an outcome; recorded as orphan-possible')
|
|
2682
|
+
forkJobs.settle(backgroundForkId, refusalStatus('fork_orphan_possible'), {
|
|
2683
|
+
forked: false, forkRef: null, sourceIntegrity: null, orphanPossible: true, retryable: false,
|
|
2684
|
+
reason: 'fork_orphan_possible', reasonCopy: writeReasonCopy('fork_orphan_possible'),
|
|
2685
|
+
}, readNow() ?? Date.now())
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
})
|
|
2689
|
+
|
|
2690
|
+
// ----------------------------------------------------- background fork status
|
|
2691
|
+
//
|
|
2692
|
+
// 6.63.0: the outcome of a fork started with a `clientForkId`. Its own path segment
|
|
2693
|
+
// (`/agent-session-forks`), so it can never be read as `/agent-sessions/:provider/:threadId`.
|
|
2694
|
+
// UNGATED, like the fork route it reports on.
|
|
2695
|
+
router.get('/agent-session-forks/:clientForkId', (req, res) => {
|
|
2696
|
+
res.set('Cache-Control', 'private, no-store')
|
|
2697
|
+
const clientForkId = String(req.params.clientForkId ?? '')
|
|
2698
|
+
if (!CLIENT_FORK_ID_RE.test(clientForkId)) {
|
|
2699
|
+
res.status(400).json({ state: 'invalid_request', reasonCopy: FORK_JOB_UNKNOWN_COPY })
|
|
2700
|
+
return
|
|
2407
2701
|
}
|
|
2702
|
+
const row = forkJobs.get(clientForkId)
|
|
2703
|
+
if (row === null) {
|
|
2704
|
+
// Not "failed": an older server, a dropped row or an id never admitted all read this way,
|
|
2705
|
+
// and any of them may have a copy.
|
|
2706
|
+
res.status(404).json({ state: 'unknown', clientForkId, reasonCopy: FORK_JOB_UNKNOWN_COPY })
|
|
2707
|
+
return
|
|
2708
|
+
}
|
|
2709
|
+
const projected = projectForkJob(row)
|
|
2710
|
+
res.status(projected.status).json(projected.body)
|
|
2408
2711
|
})
|
|
2409
2712
|
|
|
2410
2713
|
// ----------------------------------------------------------------- turns
|
|
@@ -2656,14 +2959,16 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2656
2959
|
// session started in the gap is exactly the residual risk option B leaves
|
|
2657
2960
|
// open, and it is terminal here rather than a warning because COS has no
|
|
2658
2961
|
// cross-process lock that could fence a live desktop writer.
|
|
2659
|
-
const detected =
|
|
2962
|
+
const detected = routeOccupancy(binding.provider, binding.nativeThreadId)
|
|
2660
2963
|
const verdict = projectAttachability(detected)
|
|
2661
2964
|
stage('gate')
|
|
2662
2965
|
if (!verdict.attachable) return refuseTurn(verdict.reason ?? 'probe_failed')
|
|
2663
2966
|
// 6.51.0: the SAME verdict says whether a live process that is not ours holds the
|
|
2664
|
-
// thread. Only the declared idle-holder exemption
|
|
2665
|
-
// verdict (`projectAttachability` refuses anything
|
|
2666
|
-
|
|
2967
|
+
// thread. Only the declared idle-holder exemption (or, 6.62.0, the busy Codex hop)
|
|
2968
|
+
// can carry one on an attachable verdict (`projectAttachability` refuses anything
|
|
2969
|
+
// else), so this is that fact.
|
|
2970
|
+
const busyCodexHop = detected.busyHolder === true
|
|
2971
|
+
const foreignHolder = (detected.idleHolder === true || busyCodexHop)
|
|
2667
2972
|
&& Array.isArray(detected.owners)
|
|
2668
2973
|
&& detected.owners.some(owner => owner?.selfOwned !== true)
|
|
2669
2974
|
|
|
@@ -2677,7 +2982,15 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2677
2982
|
// reads as diverged, because the first turn is what moved the head.
|
|
2678
2983
|
const baseline = guard.acknowledgedHead(bindingId) ?? binding.nativeHeadAtAttach
|
|
2679
2984
|
if (typeof baseline !== 'string' || baseline.length === 0) return refuseTurn('native_head_unavailable')
|
|
2680
|
-
if (head.digest !== baseline) {
|
|
2985
|
+
if (head.digest !== baseline && busyCodexHop) {
|
|
2986
|
+
// 6.62.0 (plan 3.6): a busy thread moves while it works; that is what busy means.
|
|
2987
|
+
// The hop puts the turn into the app's own queue, which runs it after the current
|
|
2988
|
+
// turn, the same "append to whatever the thread is when it frees" the COS queue's
|
|
2989
|
+
// watermark exemption already grants a parked turn. Recorded, so the next
|
|
2990
|
+
// interactive turn compares against this head.
|
|
2991
|
+
console.log(`[agent-session-bindings] busy codex hop acknowledges a moving head turnId=${turnId} bindingId=${bindingId}`)
|
|
2992
|
+
guard.acknowledgeHead(bindingId, head.digest)
|
|
2993
|
+
} else if (head.digest !== baseline) {
|
|
2681
2994
|
// Only a changed/not-changed signal and a new opaque revision. No diff, no
|
|
2682
2995
|
// content, no path — the client is told THAT it moved, never to what.
|
|
2683
2996
|
if (acknowledged !== head.digest) {
|
|
@@ -2813,7 +3126,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2813
3126
|
if (live?.ok) {
|
|
2814
3127
|
// 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
|
|
2815
3128
|
const latched = latchedHere()
|
|
2816
|
-
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
|
|
3129
|
+
if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
2817
3130
|
console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
|
|
2818
3131
|
respond(200, {
|
|
2819
3132
|
turnId,
|
|
@@ -2832,10 +3145,18 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2832
3145
|
// latched on the way that is the whole turn: cancelled, and no child.
|
|
2833
3146
|
const codexLatched = latchedHere()
|
|
2834
3147
|
if (codexLatched && live !== null && live.reason !== 'unverified') {
|
|
2835
|
-
settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt })
|
|
3148
|
+
settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'none', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
2836
3149
|
console.log(`[agent-session-bindings] turn cancelled during live hand-off provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
|
|
2837
3150
|
return refuseTurn('turn_cancelled', { retryable: false })
|
|
2838
3151
|
}
|
|
3152
|
+
// 6.62.0 (plan 3.6): a BUSY thread never falls through to a child (the writer lock
|
|
3153
|
+
// is held, so the spawn would fail after the prompt and fence). Whatever reached no
|
|
3154
|
+
// queue row is the 6.61 answer instead: working, and queueable, so the COS queue
|
|
3155
|
+
// takes it. A row that MAY be queued is still the ambiguous fence below.
|
|
3156
|
+
if (busyCodexHop && live !== null && live.reason !== 'unverified') {
|
|
3157
|
+
console.log(`[agent-session-bindings] busy codex hop declined provider=codex turnId=${turnId} bindingId=${bindingId} hop=${live.reason}`)
|
|
3158
|
+
return refuseTurn('native_thread_working')
|
|
3159
|
+
}
|
|
2839
3160
|
// The operator switched this off: nothing was queued, so the abortable
|
|
2840
3161
|
// 6.50 child path is cancellable again before it starts.
|
|
2841
3162
|
if (live?.reason === 'disabled' && liveEntry?.turnId === turnId) liveEntry.cancellable = true
|
|
@@ -2860,7 +3181,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2860
3181
|
})
|
|
2861
3182
|
console.warn(`[agent-session-bindings] fence set site=codex_live provider=codex target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=codex_queue_unverified`)
|
|
2862
3183
|
// 6.53.3: a row MAY be in the Codex app's queue; a latched cancel says where to stop it.
|
|
2863
|
-
if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
|
|
3184
|
+
if (codexLatched) settleLatchedCancel(codexLatched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt, busy: busyCodexHop })
|
|
2864
3185
|
return reportAmbiguous()
|
|
2865
3186
|
}
|
|
2866
3187
|
// Nothing was queued, and the child cannot run against a held thread. Not
|
|
@@ -2912,6 +3233,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
|
|
|
2912
3233
|
expectedNativeHead: head.raw,
|
|
2913
3234
|
prompt,
|
|
2914
3235
|
abortSignal: cancelController.signal,
|
|
3236
|
+
// 6.62.0 /qa (Q3): only a client that shows `continue_note` gets Cursor Run Everything.
|
|
3237
|
+
continueNoteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
|
|
2915
3238
|
onSpawn: (pid: number): boolean => {
|
|
2916
3239
|
// THE SELF-RECURSION ORDER. The child registers itself against the id
|
|
2917
3240
|
// we are targeting, so unless it is in the ledger the next occupancy
|