typeclaw 0.36.1 → 0.36.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/package.json +2 -2
- package/src/agent/index.ts +11 -0
- package/src/agent/restart/index.ts +6 -0
- package/src/agent/restart-handoff/index.ts +10 -0
- package/src/agent/tools/restart.ts +9 -0
- package/src/bundled-plugins/backup/README.md +11 -2
- package/src/bundled-plugins/backup/git-auth.ts +58 -0
- package/src/bundled-plugins/backup/index.ts +54 -0
- package/src/bundled-plugins/backup/runner.ts +82 -12
- package/src/channels/adapters/discord-bot-reactions.ts +1 -0
- package/src/channels/manager.ts +15 -3
- package/src/channels/router.ts +67 -16
- package/src/cli/hostd.ts +37 -4
- package/src/cli/ui.ts +6 -0
- package/src/container/start.ts +6 -0
- package/src/init/reconcile-plugin-deps.ts +45 -15
- package/src/init/restart-deps-preflight.ts +155 -0
- package/src/permissions/permissions.ts +24 -4
- package/src/plugin/loader.ts +16 -4
- package/src/plugin/manager.ts +175 -71
- package/src/run/codex-fetch-observer.ts +57 -5
- package/src/run/index.ts +5 -0
- package/src/sandbox/policy.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.2",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
50
|
"@mozilla/readability": "^0.6.0",
|
|
51
|
-
"agent-messenger": "2.19.
|
|
51
|
+
"agent-messenger": "2.19.2",
|
|
52
52
|
"cheerio": "^1.2.0",
|
|
53
53
|
"citty": "^0.2.2",
|
|
54
54
|
"cron-parser": "^5.5.0",
|
package/src/agent/index.ts
CHANGED
|
@@ -383,6 +383,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
383
383
|
originatingSessionId: sessionManager.getSessionId(),
|
|
384
384
|
...(options.stream ? { stream: options.stream } : {}),
|
|
385
385
|
...buildRestartHandoffWiring(options, sessionManager),
|
|
386
|
+
triggeringAuthorIdProvider: () => currentChannelAuthor(getOrigin),
|
|
386
387
|
}),
|
|
387
388
|
]
|
|
388
389
|
: []),
|
|
@@ -523,6 +524,16 @@ export function buildRestartHandoffWiring(
|
|
|
523
524
|
return { agentDir, originatingSessionFile: sessionFile, handoffOrigin }
|
|
524
525
|
}
|
|
525
526
|
|
|
527
|
+
// Reads the LIVE turn author at restart time, not the session-creation
|
|
528
|
+
// snapshot. A channel session is long-lived and multi-principal: the
|
|
529
|
+
// self-restart tool fires on whatever turn triggered it, so the handoff must
|
|
530
|
+
// carry that turn's author (originRef.current), not whoever first opened the
|
|
531
|
+
// session. Returns undefined for non-channel/no-author origins.
|
|
532
|
+
export function currentChannelAuthor(getOrigin: () => SessionOrigin | undefined): string | undefined {
|
|
533
|
+
const origin = getOrigin()
|
|
534
|
+
return origin?.kind === 'channel' ? origin.lastInboundAuthorId : undefined
|
|
535
|
+
}
|
|
536
|
+
|
|
526
537
|
function restartHandoffOriginFor(origin: SessionOrigin): RestartHandoffOrigin | null {
|
|
527
538
|
if (origin.kind === 'tui') return { kind: 'tui' }
|
|
528
539
|
if (origin.kind === 'channel') {
|
|
@@ -35,6 +35,10 @@ export type RequestContainerRestartOptions = {
|
|
|
35
35
|
// startup). Required alongside agentDir + originatingSessionFile for the
|
|
36
36
|
// handoff to be written; omitting it skips the handoff entirely.
|
|
37
37
|
handoffOrigin?: RestartHandoffOrigin
|
|
38
|
+
// Author of the inbound that owned the restarting session, carried so the
|
|
39
|
+
// reopened channel session re-seeds the requester's identity on its resume
|
|
40
|
+
// turn (see RestartHandoff.triggeringAuthorId).
|
|
41
|
+
triggeringAuthorId?: string
|
|
38
42
|
restartedAt?: string
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -54,6 +58,7 @@ export async function requestContainerRestart({
|
|
|
54
58
|
originatingSessionId,
|
|
55
59
|
originatingSessionFile,
|
|
56
60
|
handoffOrigin,
|
|
61
|
+
triggeringAuthorId,
|
|
57
62
|
restartedAt,
|
|
58
63
|
}: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
|
|
59
64
|
const request = { kind: 'restart' as const, containerName, build: build === true }
|
|
@@ -103,6 +108,7 @@ export async function requestContainerRestart({
|
|
|
103
108
|
originatingSessionId,
|
|
104
109
|
originatingSessionFile: basename(originatingSessionFile),
|
|
105
110
|
origin: handoffOrigin,
|
|
111
|
+
...(triggeringAuthorId !== undefined ? { triggeringAuthorId } : {}),
|
|
106
112
|
})
|
|
107
113
|
} catch {
|
|
108
114
|
// intentional swallow — see the post-ACK rationale above
|
|
@@ -35,6 +35,13 @@ export type RestartHandoff = {
|
|
|
35
35
|
originatingSessionId: string
|
|
36
36
|
originatingSessionFile: string
|
|
37
37
|
origin: RestartHandoffOrigin
|
|
38
|
+
// Author of the inbound that owned the originating session at restart time.
|
|
39
|
+
// The synthetic resume turn has no inbound of its own, so without this a
|
|
40
|
+
// multi-principal channel session re-seeds its turn author from nothing and
|
|
41
|
+
// an author-scoped role (`discord:* author:U_OWNER`) silently demotes to
|
|
42
|
+
// whatever bare-channel rule matches on every "I'm back" turn. Optional and
|
|
43
|
+
// additive: pre-field v2 handoffs and tui handoffs omit it.
|
|
44
|
+
triggeringAuthorId?: string
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
// Atomic write via `.tmp` + rename so a crash mid-write never leaves the
|
|
@@ -158,6 +165,9 @@ function parseHandoff(raw: string): RestartHandoff | null {
|
|
|
158
165
|
originatingSessionId: obj.originatingSessionId,
|
|
159
166
|
originatingSessionFile: obj.originatingSessionFile,
|
|
160
167
|
origin,
|
|
168
|
+
...(typeof obj.triggeringAuthorId === 'string' && obj.triggeringAuthorId !== ''
|
|
169
|
+
? { triggeringAuthorId: obj.triggeringAuthorId }
|
|
170
|
+
: {}),
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
|
|
@@ -57,6 +57,12 @@ export type CreateRestartToolOptions = {
|
|
|
57
57
|
// alongside `originatingSessionFile` for the handoff to be written; omit to
|
|
58
58
|
// skip the handoff. See buildRestartHandoffWiring in src/agent/index.ts.
|
|
59
59
|
handoffOrigin?: RestartHandoffOrigin
|
|
60
|
+
// Resolves the LIVE turn author at execute time (not session-creation), so a
|
|
61
|
+
// channel self-restart in a long-lived multi-principal session resumes under
|
|
62
|
+
// the author of the turn that triggered the restart — not whoever opened the
|
|
63
|
+
// session. Called inside execute(); returns undefined for tui/no-author
|
|
64
|
+
// origins. See RestartHandoff.triggeringAuthorId.
|
|
65
|
+
triggeringAuthorIdProvider?: () => string | undefined
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
|
|
@@ -75,6 +81,7 @@ export function createRestartTool({
|
|
|
75
81
|
agentDir,
|
|
76
82
|
originatingSessionFile,
|
|
77
83
|
handoffOrigin,
|
|
84
|
+
triggeringAuthorIdProvider,
|
|
78
85
|
}: CreateRestartToolOptions) {
|
|
79
86
|
const doExit = exit ?? ((code: number) => process.exit(code))
|
|
80
87
|
|
|
@@ -103,6 +110,7 @@ export function createRestartTool({
|
|
|
103
110
|
}),
|
|
104
111
|
async execute(_toolCallId, params) {
|
|
105
112
|
const build = params.build === true
|
|
113
|
+
const triggeringAuthorId = triggeringAuthorIdProvider?.()
|
|
106
114
|
// requestContainerRestart owns the post-ACK broadcast->handoff ordering:
|
|
107
115
|
// on a successful ACK it publishes the container-restarting notice (which
|
|
108
116
|
// every live session's subscribeRestartNotice turns into a transcript
|
|
@@ -121,6 +129,7 @@ export function createRestartTool({
|
|
|
121
129
|
...(agentDir !== undefined ? { agentDir } : {}),
|
|
122
130
|
...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
|
|
123
131
|
...(handoffOrigin !== undefined ? { handoffOrigin } : {}),
|
|
132
|
+
...(triggeringAuthorId !== undefined ? { triggeringAuthorId } : {}),
|
|
124
133
|
})
|
|
125
134
|
if (!result.ok) {
|
|
126
135
|
const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
|
|
@@ -45,7 +45,15 @@ Commit message comes from the `backup-message` subagent, which sees a truncated
|
|
|
45
45
|
|
|
46
46
|
## What it pushes
|
|
47
47
|
|
|
48
|
-
When `pushToOrigin: true
|
|
48
|
+
When `pushToOrigin: true`, the runner picks a push plan after committing:
|
|
49
|
+
|
|
50
|
+
- **Branch has an upstream** (`git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` succeeds): run `git push`.
|
|
51
|
+
- **No upstream, but `origin` exists and `HEAD` is a real branch**: run `git push -u origin HEAD:<branch>`, which pushes _and_ establishes tracking in one shot. This is the common case for a fresh agent folder nobody ran `git push -u` on by hand — previously the runner committed forever and never pushed here. Every later cycle then takes the plain-upstream path.
|
|
52
|
+
- **No `origin`, or detached `HEAD`**: commit only (a legitimate offline / no-remote state). No push is attempted and no diagnostics are written.
|
|
53
|
+
|
|
54
|
+
On non-fast-forward rejection (either push shape), the runner runs `git fetch` then `git rebase <remote-branch>` then re-pushes with the same args. Only `origin` is ever acted on for the set-upstream case — the runner never guesses a destination the operator didn't configure.
|
|
55
|
+
|
|
56
|
+
**Credentials.** The runner spawns `git` directly (not via the `bash` tool), so the `github-cli-auth` plugin's `tool.before` credential injection does **not** fire for it. For **GitHub App auth** the backup plugin mints a per-repo installation token for `origin`'s github.com slug and injects it into the runner's git env via the same `GIT_ASKPASS` helper the bash path uses (token in `TYPECLAW_GIT_TOKEN`, never in argv/config; ssh remotes rewritten to https via `insteadOf`). Classic/fine-grained PATs, SSH-key, and credential-helper setups are left untouched — the runner uses its inherited process env. Non-github origins are never minted for.
|
|
49
57
|
|
|
50
58
|
If any network step fails (rebase conflict, auth failure, network timeout), the runner aborts cleanly and spawns the `backup-diagnose` subagent. That subagent has `bash`, `read`, and `write` tools and writes a short human-readable report to `<agentDir>/sessions/backup-diagnostics.log`. The diagnose subagent is explicitly forbidden from force-pushing or resolving merge conflicts itself.
|
|
51
59
|
|
|
@@ -77,5 +85,6 @@ This feature came up as: "periodically check for dirty files and commit; LLM pic
|
|
|
77
85
|
|
|
78
86
|
## Tests
|
|
79
87
|
|
|
80
|
-
- `runner.test.ts` — deterministic runner unit tests (status parsing, force-add of `sessions/`, push-
|
|
88
|
+
- `runner.test.ts` — deterministic runner unit tests (status parsing, force-add of `sessions/`, push-with-upstream, push-and-set-upstream when origin exists but tracking is absent, commit-only on no-origin / detached-HEAD, rebase-on-non-fast-forward for both push shapes, diagnose-on-rebase-conflict, advisory-throw isolation, sanitize-commit-message).
|
|
89
|
+
- `git-auth.test.ts` — credential-env resolution (App-auth mints for the origin slug; PAT/SSH/non-github/unavailable-token all fall back to inherited env).
|
|
81
90
|
- `index.test.ts` — plugin composition tests (subagent/hook surface, config schema defaults and validation, debounce, active-turn gating, self-induced-turn exclusion, coalescing).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { GithubTokenResolveResult } from '@/channels/github-token-bridge'
|
|
2
|
+
|
|
3
|
+
import { ensureGitAskPassHelper } from '../github-cli-auth/git-askpass'
|
|
4
|
+
import { parseGithubRepoFromGitUrl } from '../github-cli-auth/git-command'
|
|
5
|
+
import { shouldMintAppToken } from '../github-cli-auth/token-class'
|
|
6
|
+
|
|
7
|
+
export type BackupGitAuthEnv = Record<string, string>
|
|
8
|
+
|
|
9
|
+
export type BackupPushAuthDeps = {
|
|
10
|
+
hasAppTokenResolver: () => boolean
|
|
11
|
+
ghToken: string | undefined
|
|
12
|
+
resolveTokenForRepo: (repoSlug: string) => Promise<GithubTokenResolveResult>
|
|
13
|
+
resolveOriginPushUrl: (cwd: string) => Promise<string | null>
|
|
14
|
+
ensureAskPassHelper: () => Promise<string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The backup runner spawns git directly (not via the bash tool), so the
|
|
18
|
+
// `github-cli-auth` plugin's `tool.before` credential injection never fires for
|
|
19
|
+
// its push. Without this, App-auth agents push with no credentials and fail.
|
|
20
|
+
// We mirror that plugin exactly: only mint for App auth, only for a github.com
|
|
21
|
+
// origin, scoped to the origin's own repo slug. PAT/SSH/credential-helper setups
|
|
22
|
+
// return null and keep using the runner's inherited process env.
|
|
23
|
+
export async function resolveBackupPushAuthEnv(
|
|
24
|
+
cwd: string,
|
|
25
|
+
deps: BackupPushAuthDeps,
|
|
26
|
+
): Promise<BackupGitAuthEnv | null> {
|
|
27
|
+
if (!shouldMintAppToken(deps.ghToken, deps.hasAppTokenResolver())) return null
|
|
28
|
+
|
|
29
|
+
const originUrl = await deps.resolveOriginPushUrl(cwd)
|
|
30
|
+
if (originUrl === null) return null
|
|
31
|
+
|
|
32
|
+
const slug = parseGithubRepoFromGitUrl(originUrl)
|
|
33
|
+
if (slug === null) return null
|
|
34
|
+
|
|
35
|
+
const token = await deps.resolveTokenForRepo(slug)
|
|
36
|
+
if (token.kind !== 'token') return null
|
|
37
|
+
|
|
38
|
+
const askpass = await deps.ensureAskPassHelper()
|
|
39
|
+
|
|
40
|
+
// Token rides in TYPECLAW_GIT_TOKEN (read by the askpass helper), never in
|
|
41
|
+
// argv/config. The insteadOf rewrites map ssh/scp github remotes to https so
|
|
42
|
+
// the askpass credential applies; GIT_TERMINAL_PROMPT=0 fails fast instead of
|
|
43
|
+
// hanging on a prompt. Mirrors github-cli-auth/index.ts.
|
|
44
|
+
return {
|
|
45
|
+
GIT_ASKPASS: askpass,
|
|
46
|
+
TYPECLAW_GIT_TOKEN: token.token,
|
|
47
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
48
|
+
GIT_CONFIG_COUNT: '2',
|
|
49
|
+
GIT_CONFIG_KEY_0: 'url.https://github.com/.insteadOf',
|
|
50
|
+
GIT_CONFIG_VALUE_0: 'git@github.com:',
|
|
51
|
+
GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf',
|
|
52
|
+
GIT_CONFIG_VALUE_1: 'ssh://git@github.com/',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function makeDefaultAskPassEnsurer(): () => Promise<string> {
|
|
57
|
+
return () => ensureGitAskPassHelper()
|
|
58
|
+
}
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { withGitLock } from '@/git/mutex'
|
|
4
4
|
import { definePlugin, type PluginContext, type SpawnSubagentOptions, type Subagent } from '@/plugin'
|
|
5
5
|
|
|
6
|
+
import { type BackupPushAuthDeps, makeDefaultAskPassEnsurer, resolveBackupPushAuthEnv } from './git-auth'
|
|
6
7
|
import { COMMIT_TIMEOUT_MS, makeDefaultGitSpawn, NETWORK_TIMEOUT_MS, runBackup, type BackupResult } from './runner'
|
|
7
8
|
import {
|
|
8
9
|
cleanupMessageFile,
|
|
@@ -163,6 +164,7 @@ async function runBackupOnce(
|
|
|
163
164
|
agentDir: string
|
|
164
165
|
logger: { info: (m: string) => void; warn: (m: string) => void }
|
|
165
166
|
spawnSubagent: PluginContext['spawnSubagent']
|
|
167
|
+
github: PluginContext['github']
|
|
166
168
|
},
|
|
167
169
|
): Promise<BackupResult> {
|
|
168
170
|
const messagePath = messageFilePath(payload.agentDir)
|
|
@@ -175,11 +177,21 @@ async function runBackupOnce(
|
|
|
175
177
|
spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' },
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
// App-auth agents need a minted per-repo token for the runner's push (it
|
|
181
|
+
// bypasses the bash tool's credential hook). Only computed when we'll push;
|
|
182
|
+
// PAT/SSH/non-github fall back to null. Passed as `pushEnv` so the runner
|
|
183
|
+
// applies it to push/fetch ONLY — never to local commands like `git commit`,
|
|
184
|
+
// which can run repo-controlled hooks that would otherwise see the token.
|
|
185
|
+
const pushEnv = payload.pushToOrigin
|
|
186
|
+
? ((await resolveBackupAuthEnv(payload.agentDir, ctx.github, ctx.logger)) ?? undefined)
|
|
187
|
+
: undefined
|
|
188
|
+
|
|
178
189
|
const result = await withGitLock(payload.agentDir, () =>
|
|
179
190
|
runBackup(
|
|
180
191
|
{ cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
|
|
181
192
|
{
|
|
182
193
|
gitSpawn: makeDefaultGitSpawn(),
|
|
194
|
+
pushEnv,
|
|
183
195
|
pickCommitMessage: async ({ status, diffstat }) => {
|
|
184
196
|
await cleanupMessageFile(messagePath)
|
|
185
197
|
const messagePayload: CommitMessagePayload = {
|
|
@@ -221,6 +233,48 @@ async function runBackupOnce(
|
|
|
221
233
|
return result
|
|
222
234
|
}
|
|
223
235
|
|
|
236
|
+
async function resolveBackupAuthEnv(
|
|
237
|
+
agentDir: string,
|
|
238
|
+
github: PluginContext['github'],
|
|
239
|
+
logger: { warn: (m: string) => void },
|
|
240
|
+
): Promise<Record<string, string> | null> {
|
|
241
|
+
const deps: BackupPushAuthDeps = {
|
|
242
|
+
hasAppTokenResolver: github.hasAppTokenResolver,
|
|
243
|
+
ghToken: process.env.GH_TOKEN,
|
|
244
|
+
resolveTokenForRepo: github.resolveTokenForRepo,
|
|
245
|
+
resolveOriginPushUrl,
|
|
246
|
+
ensureAskPassHelper: makeDefaultAskPassEnsurer(),
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
return await resolveBackupPushAuthEnv(agentDir, deps)
|
|
250
|
+
} catch {
|
|
251
|
+
// Credential prep is best-effort: a resolver failure must not abort the
|
|
252
|
+
// backup. Falls back to the runner's inherited env (commit still happens),
|
|
253
|
+
// and the push's own failure path diagnoses if it then can't authenticate.
|
|
254
|
+
// No slug/token/error detail is logged — those are credential-adjacent.
|
|
255
|
+
logger.warn('GitHub backup auth unavailable; continuing with inherited git credentials')
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function resolveOriginPushUrl(cwd: string): Promise<string | null> {
|
|
261
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
262
|
+
if (!bun) return null
|
|
263
|
+
try {
|
|
264
|
+
const proc = bun.spawn({
|
|
265
|
+
cmd: ['git', '-C', cwd, 'remote', 'get-url', '--push', 'origin'],
|
|
266
|
+
stdout: 'pipe',
|
|
267
|
+
stderr: 'ignore',
|
|
268
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
|
|
269
|
+
})
|
|
270
|
+
if ((await proc.exited) !== 0) return null
|
|
271
|
+
const out = (await new Response(proc.stdout).text()).trim()
|
|
272
|
+
return out === '' ? null : out
|
|
273
|
+
} catch {
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
224
278
|
function describeResult(r: BackupResult): string {
|
|
225
279
|
if (r.ok) return r.kind
|
|
226
280
|
return `failed (${r.kind}): ${r.reason}`
|
|
@@ -21,12 +21,20 @@ export type GitSpawnResult = {
|
|
|
21
21
|
timedOut: boolean
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export type GitSpawn = (
|
|
24
|
+
export type GitSpawn = (
|
|
25
|
+
args: readonly string[],
|
|
26
|
+
opts: { cwd: string; timeoutMs: number; env?: Record<string, string> },
|
|
27
|
+
) => Promise<GitSpawnResult>
|
|
25
28
|
|
|
26
29
|
export type BackupRunnerDeps = {
|
|
27
30
|
gitSpawn: GitSpawn
|
|
28
31
|
pickCommitMessage: (input: { status: string; diffstat: string }) => Promise<string>
|
|
29
32
|
diagnoseFailure?: (input: BackupFailureInput) => Promise<void>
|
|
33
|
+
// Credential env (GIT_ASKPASS/TYPECLAW_GIT_TOKEN/insteadOf) applied ONLY to
|
|
34
|
+
// network git invocations (push/fetch). It is deliberately NOT given to local
|
|
35
|
+
// commands — `git commit` can run repo-controlled hooks, which must never see
|
|
36
|
+
// the minted token.
|
|
37
|
+
pushEnv?: Record<string, string>
|
|
30
38
|
now?: () => number
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -44,9 +52,15 @@ export type BackupFailureInput = {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export type BackupResult =
|
|
47
|
-
| { ok: true; kind: 'no-repo' | 'clean' | 'committed' | 'pushed' | 'rebased-and-pushed' }
|
|
55
|
+
| { ok: true; kind: 'no-repo' | 'clean' | 'committed' | 'pushed' | 'pushed-set-upstream' | 'rebased-and-pushed' }
|
|
48
56
|
| { ok: false; kind: 'commit-failed' | 'push-failed' | 'rebase-failed' | 'aborted'; reason: string }
|
|
49
57
|
|
|
58
|
+
type ActivePushPlan =
|
|
59
|
+
| { kind: 'upstream'; upstreamRef: string }
|
|
60
|
+
| { kind: 'set-upstream'; remote: string; branch: string }
|
|
61
|
+
|
|
62
|
+
type PushPlan = ActivePushPlan | { kind: 'skip' }
|
|
63
|
+
|
|
50
64
|
export async function runBackup(options: BackupRunnerOptions, deps: BackupRunnerDeps): Promise<BackupResult> {
|
|
51
65
|
const { cwd, pushToOrigin } = options
|
|
52
66
|
|
|
@@ -113,24 +127,70 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
113
127
|
|
|
114
128
|
if (!pushToOrigin) return { ok: true, kind: 'committed' }
|
|
115
129
|
|
|
130
|
+
const plan = await resolvePushPlan(cwd, deps)
|
|
131
|
+
if (plan.kind === 'skip') return { ok: true, kind: 'committed' }
|
|
132
|
+
|
|
133
|
+
return pushWithRecovery(cwd, deps, plan)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `@{upstream}` resolution failing was previously treated as "no push" — but a
|
|
137
|
+
// fresh agent repo that nobody ran `git push -u` on has a configured `origin`
|
|
138
|
+
// and no tracking ref, so the runner committed forever and never pushed. The
|
|
139
|
+
// correct gate when `pushToOrigin` is on is "origin exists and HEAD is a real
|
|
140
|
+
// branch": then we push AND set the upstream in one shot, and every later run
|
|
141
|
+
// takes the plain-upstream path. No remote / detached HEAD stays commit-only
|
|
142
|
+
// (a legitimate offline state), so it returns `skip` rather than diagnosing.
|
|
143
|
+
async function resolvePushPlan(cwd: string, deps: BackupRunnerDeps): Promise<PushPlan> {
|
|
116
144
|
const upstream = await deps.gitSpawn(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
|
|
117
145
|
cwd,
|
|
118
146
|
timeoutMs: COMMIT_TIMEOUT_MS,
|
|
119
147
|
})
|
|
120
|
-
if (upstream.exitCode
|
|
148
|
+
if (upstream.exitCode === 0 && upstream.stdout.trim().length > 0) {
|
|
149
|
+
return { kind: 'upstream', upstreamRef: upstream.stdout.trim() }
|
|
150
|
+
}
|
|
121
151
|
|
|
122
|
-
|
|
123
|
-
|
|
152
|
+
// Only `origin` is acted on: picking "the first remote" when origin is absent
|
|
153
|
+
// would guess a destination the operator never configured. `get-url` (not
|
|
154
|
+
// `get-url --push`) is enough here — we only need to know origin EXISTS and
|
|
155
|
+
// is named `origin`; the push targets `origin` by name regardless of pushurl.
|
|
156
|
+
const origin = await deps.gitSpawn(['remote', 'get-url', 'origin'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
157
|
+
if (origin.exitCode !== 0 || origin.stdout.trim().length === 0) return { kind: 'skip' }
|
|
124
158
|
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
// `symbolic-ref --short HEAD` fails on a detached HEAD (no branch to set an
|
|
160
|
+
// upstream for); `rev-parse --abbrev-ref HEAD` would have returned the literal
|
|
161
|
+
// "HEAD" and we'd have tried to push a branch named HEAD. Skip cleanly.
|
|
162
|
+
const branch = await deps.gitSpawn(['symbolic-ref', '--short', 'HEAD'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
163
|
+
if (branch.exitCode !== 0 || branch.stdout.trim().length === 0) return { kind: 'skip' }
|
|
164
|
+
|
|
165
|
+
return { kind: 'set-upstream', remote: 'origin', branch: branch.stdout.trim() }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Both entry points (plain push and first-time set-upstream push) share the
|
|
169
|
+
// non-fast-forward recovery: fetch, rebase onto the intended remote branch,
|
|
170
|
+
// re-push. Keeping one helper stops the set-upstream path from silently becoming
|
|
171
|
+
// a weaker duplicate that skips recovery.
|
|
172
|
+
async function pushWithRecovery(cwd: string, deps: BackupRunnerDeps, plan: ActivePushPlan): Promise<BackupResult> {
|
|
173
|
+
const pushArgs = pushArgsFor(plan)
|
|
174
|
+
const rebaseRef = plan.kind === 'upstream' ? plan.upstreamRef : `${plan.remote}/${plan.branch}`
|
|
175
|
+
// In the set-upstream case there is no tracking ref yet, so a bare `git fetch`
|
|
176
|
+
// has no configured remote to default to — fetch the same remote we rebase
|
|
177
|
+
// onto. The upstream case keeps bare `fetch` (its tracking config resolves it).
|
|
178
|
+
const fetchArgs = plan.kind === 'upstream' ? ['fetch'] : ['fetch', plan.remote]
|
|
179
|
+
const pushedKind: BackupResult = { ok: true, kind: plan.kind === 'upstream' ? 'pushed' : 'pushed-set-upstream' }
|
|
180
|
+
// Credentials ride ONLY on the network calls (push/fetch). The rebase is
|
|
181
|
+
// local (it replays onto an already-fetched remote-tracking ref), so it runs
|
|
182
|
+
// token-free like every other local command.
|
|
183
|
+
const net = { cwd, timeoutMs: NETWORK_TIMEOUT_MS, env: deps.pushEnv }
|
|
184
|
+
|
|
185
|
+
const push = await deps.gitSpawn(pushArgs, net)
|
|
186
|
+
if (push.exitCode === 0) return pushedKind
|
|
127
187
|
|
|
128
188
|
if (!isNonFastForward(push)) {
|
|
129
189
|
await maybeDiagnose(deps, { cwd, stage: 'push', exitCode: push.exitCode, stderr: push.stderr, stdout: push.stdout })
|
|
130
190
|
return { ok: false, kind: 'push-failed', reason: shortErr(push) }
|
|
131
191
|
}
|
|
132
192
|
|
|
133
|
-
const fetch = await deps.gitSpawn(
|
|
193
|
+
const fetch = await deps.gitSpawn(fetchArgs, net)
|
|
134
194
|
if (fetch.exitCode !== 0) {
|
|
135
195
|
await maybeDiagnose(deps, {
|
|
136
196
|
cwd,
|
|
@@ -142,7 +202,7 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
142
202
|
return { ok: false, kind: 'push-failed', reason: `git fetch failed: ${shortErr(fetch)}` }
|
|
143
203
|
}
|
|
144
204
|
|
|
145
|
-
const rebase = await deps.gitSpawn(['rebase',
|
|
205
|
+
const rebase = await deps.gitSpawn(['rebase', rebaseRef], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
|
|
146
206
|
if (rebase.exitCode !== 0) {
|
|
147
207
|
await deps.gitSpawn(['rebase', '--abort'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
148
208
|
await maybeDiagnose(deps, {
|
|
@@ -155,7 +215,7 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
155
215
|
return { ok: false, kind: 'rebase-failed', reason: `git rebase failed: ${shortErr(rebase)}` }
|
|
156
216
|
}
|
|
157
217
|
|
|
158
|
-
const push2 = await deps.gitSpawn(
|
|
218
|
+
const push2 = await deps.gitSpawn(pushArgs, net)
|
|
159
219
|
if (push2.exitCode !== 0) {
|
|
160
220
|
await maybeDiagnose(deps, {
|
|
161
221
|
cwd,
|
|
@@ -169,6 +229,13 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
|
|
|
169
229
|
return { ok: true, kind: 'rebased-and-pushed' }
|
|
170
230
|
}
|
|
171
231
|
|
|
232
|
+
function pushArgsFor(plan: ActivePushPlan): string[] {
|
|
233
|
+
if (plan.kind === 'upstream') return ['push']
|
|
234
|
+
// `HEAD:<branch>` is explicit about pushing the current commit to the named
|
|
235
|
+
// remote branch, avoiding any reliance on local refspec defaults.
|
|
236
|
+
return ['push', '-u', plan.remote, `HEAD:${plan.branch}`]
|
|
237
|
+
}
|
|
238
|
+
|
|
172
239
|
async function maybeDiagnose(deps: BackupRunnerDeps, input: BackupFailureInput): Promise<void> {
|
|
173
240
|
if (!deps.diagnoseFailure) return
|
|
174
241
|
try {
|
|
@@ -217,7 +284,7 @@ function sanitizeCommitMessage(raw: string): string {
|
|
|
217
284
|
}
|
|
218
285
|
|
|
219
286
|
export function makeDefaultGitSpawn(): GitSpawn {
|
|
220
|
-
return withIndexLockRetry(async (args, { cwd, timeoutMs }) => {
|
|
287
|
+
return withIndexLockRetry(async (args, { cwd, timeoutMs, env }) => {
|
|
221
288
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
222
289
|
if (!bun) {
|
|
223
290
|
return { exitCode: 127, stdout: '', stderr: 'Bun runtime not available', timedOut: false }
|
|
@@ -225,12 +292,15 @@ export function makeDefaultGitSpawn(): GitSpawn {
|
|
|
225
292
|
const controller = new AbortController()
|
|
226
293
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
227
294
|
try {
|
|
295
|
+
// Per-call `env` (credentials for push/fetch) is applied LAST so its
|
|
296
|
+
// GIT_TERMINAL_PROMPT wins; NONINTERACTIVE_ENV's pager/GCM settings still
|
|
297
|
+
// apply to every call. Local commands pass no `env` and stay token-free.
|
|
228
298
|
const proc = bun.spawn({
|
|
229
299
|
cmd: ['git', ...args],
|
|
230
300
|
cwd,
|
|
231
301
|
stdout: 'pipe',
|
|
232
302
|
stderr: 'pipe',
|
|
233
|
-
env: { ...process.env, ...NONINTERACTIVE_ENV },
|
|
303
|
+
env: { ...process.env, ...NONINTERACTIVE_ENV, ...env },
|
|
234
304
|
signal: controller.signal,
|
|
235
305
|
})
|
|
236
306
|
const exitCode = await proc.exited
|
package/src/channels/manager.ts
CHANGED
|
@@ -297,10 +297,22 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
297
297
|
|
|
298
298
|
async start(): Promise<void> {
|
|
299
299
|
const cfg = options.channelsConfigRef()
|
|
300
|
-
|
|
300
|
+
// Safe to fan out: `live` and every router registry are keyed by adapter
|
|
301
|
+
// name, so concurrent starts never collide. Serial start would otherwise pay
|
|
302
|
+
// the sum of each adapter's connect latency instead of just the slowest.
|
|
303
|
+
const starts = ADAPTER_IDS.flatMap((name) => {
|
|
301
304
|
const adapterCfg = cfg[name]
|
|
302
|
-
|
|
303
|
-
}
|
|
305
|
+
return adapterCfg === undefined ? [] : [runSerially(name, () => startAdapter(name, adapterCfg))]
|
|
306
|
+
})
|
|
307
|
+
// Await every launched start to settle BEFORE surfacing a failure.
|
|
308
|
+
// `startAdapter` converts expected per-adapter failures to `false`, so a
|
|
309
|
+
// rejection is an unexpected throw (e.g. `buildAdapter`) that must still
|
|
310
|
+
// fail-fast. But bailing on the first rejection (plain `Promise.all`) would
|
|
311
|
+
// leave sibling starts in flight, letting a late `live.set` orphan an adapter
|
|
312
|
+
// that the caller's subsequent `stop()` never sees. Settle all, then rethrow.
|
|
313
|
+
const results = await Promise.allSettled(starts)
|
|
314
|
+
const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
|
315
|
+
if (failure !== undefined) throw failure.reason
|
|
304
316
|
},
|
|
305
317
|
|
|
306
318
|
async stop(): Promise<void> {
|