docs(multitenant): add agent worktree guide and fix registry semantics
--- Build: pass | Tests: FAIL — Tests 6 failed | 1835 passed (1841)
This commit is contained in:
parent
153c8a77a2
commit
8cd208dc8c
5 changed files with 361 additions and 34 deletions
59
docs/internal/AGENT-WORKFLOW-CHECKLIST.md
Normal file
59
docs/internal/AGENT-WORKFLOW-CHECKLIST.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Agent Workflow Checklist
|
||||
|
||||
Short operator checklist for the per-agent worktree model.
|
||||
|
||||
## Before Starting
|
||||
|
||||
- verify `git config core.hooksPath` returns `hooks`
|
||||
- verify `git config extensions.worktreeConfig` is `true`
|
||||
- confirm the primary FreeBSD checkout is the only place that will push
|
||||
`multitenant`
|
||||
|
||||
## When Assigning A Linux Agent
|
||||
|
||||
- tell the agent to pull latest `origin/multitenant`
|
||||
- tell the agent to read:
|
||||
- [MULTITENANT-AGENT-WORKFLOW.md](./MULTITENANT-AGENT-WORKFLOW.md)
|
||||
- [MULTITENANT-HANDOFF.md](./MULTITENANT-HANDOFF.md)
|
||||
- require a dedicated worktree and feature branch
|
||||
- require worktree-local identity:
|
||||
- `Operator & zAI`
|
||||
- `Operator & Claude`
|
||||
- require branch-only push, never `multitenant`
|
||||
|
||||
## Before The Agent Commits
|
||||
|
||||
- confirm `git var GIT_AUTHOR_IDENT`
|
||||
- confirm `git var GIT_COMMITTER_IDENT`
|
||||
- both should start with `Operator &`
|
||||
|
||||
## Before The Agent Pushes
|
||||
|
||||
- rebase on latest `origin/multitenant`
|
||||
- if local commits were rewritten, push with `--force-with-lease`
|
||||
- report the feature branch name and tip commit hash
|
||||
|
||||
## Before Codex Integrates
|
||||
|
||||
- fetch latest refs
|
||||
- verify the helper branch is actually ahead of `origin/multitenant`
|
||||
- review the diff
|
||||
- fast-forward integrate only
|
||||
- run the relevant FreeBSD verification
|
||||
|
||||
## Before Pushing `multitenant`
|
||||
|
||||
- confirm local `multitenant` is based on latest `origin/multitenant`
|
||||
- confirm the working tree is clean
|
||||
- push only after integration and verification are done
|
||||
|
||||
## If Something Looks Wrong
|
||||
|
||||
- commit rejected by hook:
|
||||
- check worktree-local `user.name`
|
||||
- helper branch equals base branch:
|
||||
- there is no new work yet
|
||||
- push rejected non-fast-forward:
|
||||
- fetch and rebase before retrying
|
||||
- rebase asks for an editor:
|
||||
- use `GIT_EDITOR=true git rebase --continue`
|
||||
245
docs/internal/AGENT-WORKTREE-WORKFLOW.md
Normal file
245
docs/internal/AGENT-WORKTREE-WORKFLOW.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# Agent Worktree Workflow
|
||||
|
||||
Practical guide for running more than one coding agent against this repo
|
||||
without corrupting attribution, stash state, or branch history.
|
||||
|
||||
## Why This Exists
|
||||
|
||||
We hit two different failures while multiple agents were sharing one clone:
|
||||
|
||||
- commit attribution drifted because `user.name` is only one mutable slot
|
||||
- `git pull` / rebase work collided because one working tree also means one
|
||||
index, one stash stack, and one rebase state directory
|
||||
|
||||
Per-agent worktrees fix both at once.
|
||||
|
||||
## The Model
|
||||
|
||||
- Linux helper agents each get their own git worktree
|
||||
- each helper works on its own feature branch:
|
||||
- `multitenant-zai`
|
||||
- `multitenant-claude`
|
||||
- FreeBSD Codex stays on the primary `multitenant` checkout
|
||||
- only FreeBSD Codex pushes `multitenant`
|
||||
|
||||
That means:
|
||||
|
||||
- helpers propose changes
|
||||
- Codex reviews, tests, integrates, and pushes the shared branch
|
||||
|
||||
## One-Time Setup
|
||||
|
||||
Run once in the primary clone:
|
||||
|
||||
```sh
|
||||
git config extensions.worktreeConfig true
|
||||
git config core.hooksPath hooks
|
||||
```
|
||||
|
||||
Why:
|
||||
|
||||
- `extensions.worktreeConfig=true` enables per-worktree `user.name`
|
||||
- `core.hooksPath hooks` activates the attribution guard and README sync hook
|
||||
|
||||
## Per-Agent Setup
|
||||
|
||||
Example for zAI:
|
||||
|
||||
```sh
|
||||
git worktree add -b multitenant-zai ../mevy-ai-zai multitenant
|
||||
cd ../mevy-ai-zai
|
||||
git config --worktree user.name "Operator & zAI"
|
||||
git config --worktree user.email "hello@clawdie.si"
|
||||
```
|
||||
|
||||
Example for Claude:
|
||||
|
||||
```sh
|
||||
git worktree add -b multitenant-claude ../mevy-ai-claude multitenant
|
||||
cd ../mevy-ai-claude
|
||||
git config --worktree user.name "Operator & Claude"
|
||||
git config --worktree user.email "hello@clawdie.si"
|
||||
```
|
||||
|
||||
Check the effective identity before committing:
|
||||
|
||||
```sh
|
||||
git var GIT_AUTHOR_IDENT
|
||||
git var GIT_COMMITTER_IDENT
|
||||
```
|
||||
|
||||
Both must start with `Operator & ...`.
|
||||
|
||||
## Day-To-Day Flow
|
||||
|
||||
### Linux helper agent
|
||||
|
||||
1. Pull latest refs:
|
||||
|
||||
```sh
|
||||
git fetch origin
|
||||
```
|
||||
|
||||
2. Work only in the agent worktree, never in the shared clone.
|
||||
|
||||
3. Commit locally on the feature branch.
|
||||
|
||||
4. Rebase on latest `origin/multitenant` before push:
|
||||
|
||||
```sh
|
||||
git pull --rebase origin multitenant
|
||||
```
|
||||
|
||||
5. Push the feature branch:
|
||||
|
||||
```sh
|
||||
git push origin multitenant-zai
|
||||
```
|
||||
|
||||
If the rebase rewrote local commits, push with lease protection:
|
||||
|
||||
```sh
|
||||
git push --force-with-lease origin multitenant-zai
|
||||
```
|
||||
|
||||
6. Report back the feature branch name and tip commit to Codex.
|
||||
|
||||
### FreeBSD Codex
|
||||
|
||||
1. Fetch latest refs:
|
||||
|
||||
```sh
|
||||
git fetch origin
|
||||
```
|
||||
|
||||
2. Review the helper branch.
|
||||
|
||||
3. Integrate into local `multitenant` with fast-forward merge only.
|
||||
|
||||
4. Run the FreeBSD-side verification that matters for the slice.
|
||||
|
||||
5. Push `multitenant`:
|
||||
|
||||
```sh
|
||||
git push origin multitenant
|
||||
```
|
||||
|
||||
## Why Integration Should Stay Fast-Forward
|
||||
|
||||
The helper branch is required to rebase on latest `origin/multitenant`
|
||||
before push. That keeps integration simple:
|
||||
|
||||
- no merge bubble just to land helper work
|
||||
- no ambiguity about who last reconciled the branch
|
||||
- if a helper branch cannot fast-forward cleanly anymore, it needs another
|
||||
rebase before Codex should integrate it
|
||||
|
||||
## What The Hook Guards
|
||||
|
||||
`hooks/pre-commit` currently does two things:
|
||||
|
||||
- rejects commits whose effective author/committer name is not
|
||||
`Operator & <agent>` or the temporary legacy `Clawdie AI`
|
||||
- runs the README version sync helper
|
||||
|
||||
The important detail is that it validates:
|
||||
|
||||
- `git var GIT_AUTHOR_IDENT`
|
||||
- `git var GIT_COMMITTER_IDENT`
|
||||
|
||||
That means it checks the identity git will actually write, not just
|
||||
`git config user.name`.
|
||||
|
||||
## Common Failure Modes
|
||||
|
||||
### Commit rejected as `Mevy Assistant`
|
||||
|
||||
Cause:
|
||||
|
||||
- the worktree is still using `.git/config` identity instead of
|
||||
`.git/config.worktree`
|
||||
|
||||
Fix:
|
||||
|
||||
```sh
|
||||
git config --worktree user.name "Operator & Codex"
|
||||
git config --worktree user.email "hello@clawdie.si"
|
||||
```
|
||||
|
||||
Then re-check with:
|
||||
|
||||
```sh
|
||||
git var GIT_AUTHOR_IDENT
|
||||
```
|
||||
|
||||
### Helper branch is identical to `origin/multitenant`
|
||||
|
||||
Cause:
|
||||
|
||||
- the helper created or pushed the branch but never committed the actual work
|
||||
|
||||
Check:
|
||||
|
||||
```sh
|
||||
git log --oneline origin/multitenant..HEAD
|
||||
```
|
||||
|
||||
If empty, there is nothing to integrate yet.
|
||||
|
||||
### `git push origin multitenant` is rejected
|
||||
|
||||
Cause:
|
||||
|
||||
- local `multitenant` is behind the remote tip
|
||||
|
||||
Fix:
|
||||
|
||||
- fetch
|
||||
- rebase local Codex commits on `origin/multitenant`
|
||||
- resolve any conflicts
|
||||
- push again
|
||||
|
||||
### `git rebase --continue` opens `vi`
|
||||
|
||||
Cause:
|
||||
|
||||
- git wants to reopen the commit message
|
||||
|
||||
Fix:
|
||||
|
||||
```sh
|
||||
GIT_EDITOR=true git rebase --continue
|
||||
```
|
||||
|
||||
### Hook is not firing
|
||||
|
||||
Check:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
hooks
|
||||
```
|
||||
|
||||
If not, run:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath hooks
|
||||
```
|
||||
|
||||
## When To Document, Not Automate
|
||||
|
||||
This workflow is worth a repo doc before it becomes a reusable skill.
|
||||
|
||||
Why:
|
||||
|
||||
- it is mostly operator process and branch discipline
|
||||
- it is tightly coupled to this repo's `multitenant` integration model
|
||||
- the value right now is a stable human runbook
|
||||
|
||||
If the same pattern starts repeating across repos, then it is worth turning
|
||||
into a skill with setup prompts and validation commands.
|
||||
0
hooks/pre-commit
Normal file → Executable file
0
hooks/pre-commit
Normal file → Executable file
|
|
@ -85,13 +85,24 @@ describe('tenant-registry', () => {
|
|||
const yamlContent = fs.readFileSync(registryPath, 'utf-8');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`${yamlContent} sites:\n - id: Blog\n - id: docs\n exposure: disabled\n`,
|
||||
yamlContent.replace(
|
||||
' sites:\n - id: blog\n exposure: internal',
|
||||
' sites:\n - id: Blog\n - id: docs\n exposure: disabled',
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
const registry = loadTenantRegistry(registryPath);
|
||||
expect(registry.tenants.mevy?.sites).toEqual([
|
||||
{ id: 'blog', exposure: 'internal' },
|
||||
{ id: 'docs', exposure: 'disabled' },
|
||||
{
|
||||
id: 'blog',
|
||||
exposure: 'internal',
|
||||
fqdn: 'blog.mevy.home.arpa',
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
exposure: 'disabled',
|
||||
fqdn: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -100,11 +111,14 @@ describe('tenant-registry', () => {
|
|||
const yamlContent = fs.readFileSync(registryPath, 'utf-8');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`${yamlContent} sites:\n - id: blog\n exposure: public\n`,
|
||||
yamlContent.replace(
|
||||
' sites:\n - id: blog\n exposure: internal',
|
||||
' sites:\n - id: blog\n exposure: public',
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
expect(() => loadTenantRegistry(registryPath)).toThrow(
|
||||
/publishing_mode is disabled/,
|
||||
/platform\.publishing_mode is disabled/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -117,7 +131,10 @@ describe('tenant-registry', () => {
|
|||
);
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`${withPublishing} sites:\n - id: blog\n exposure: public\n`,
|
||||
withPublishing.replace(
|
||||
' sites:\n - id: blog\n exposure: internal',
|
||||
' sites:\n - id: blog\n exposure: public',
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
expect(() => loadTenantRegistry(registryPath)).toThrow(
|
||||
|
|
@ -130,10 +147,13 @@ describe('tenant-registry', () => {
|
|||
const yamlContent = fs.readFileSync(registryPath, 'utf-8');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`${yamlContent} sites:\n - id: blog\n - id: Blog\n`,
|
||||
yamlContent.replace(
|
||||
' sites:\n - id: blog\n exposure: internal',
|
||||
' sites:\n - id: blog\n - id: Blog',
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
expect(() => loadTenantRegistry(registryPath)).toThrow(/duplicate site id/);
|
||||
expect(() => loadTenantRegistry(registryPath)).toThrow(/duplicated/);
|
||||
});
|
||||
|
||||
it('lists tenants in a stable order', () => {
|
||||
|
|
@ -575,15 +595,20 @@ describe('tenant-registry', () => {
|
|||
const original = fs.readFileSync(registryPath, 'utf-8');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
original.replace(
|
||||
' sites:\n - id: blog\n exposure: internal',
|
||||
' sites:\n - id: docs\n exposure: public',
|
||||
),
|
||||
original
|
||||
.replace(
|
||||
' publishing_mode: disabled',
|
||||
' publishing_mode: public',
|
||||
)
|
||||
.replace(
|
||||
' sites:\n - id: blog\n exposure: internal',
|
||||
' sites:\n - id: docs\n exposure: public',
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadTenantRegistry(registryPath)).toThrow(
|
||||
'Tenant site requires platform.public_base for public exposure: docs',
|
||||
'Tenant site requires platform.public_base; public_base is unset: docs',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -603,7 +628,7 @@ describe('tenant-registry', () => {
|
|||
);
|
||||
|
||||
expect(() => loadTenantRegistry(registryPath)).toThrow(
|
||||
'Tenant site cannot use public exposure when platform publishing mode is internal: docs',
|
||||
'Tenant site cannot use public exposure when platform.publishing_mode is internal: docs',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -238,25 +238,23 @@ function parseTenantRecord(
|
|||
internalBase?: string,
|
||||
publicBase?: string,
|
||||
): TenantRecord {
|
||||
const sites = (raw.sites || []).map((site) => {
|
||||
const exposure = site.exposure || 'internal';
|
||||
return {
|
||||
id: normalizeResourceId(site.id),
|
||||
exposure,
|
||||
fqdn:
|
||||
exposure === 'public' && !publicBase
|
||||
? null
|
||||
: siteFqdn(site.id, id, exposure, internalBase, publicBase),
|
||||
};
|
||||
});
|
||||
return {
|
||||
id,
|
||||
displayName: raw.display_name || defaultTenantDisplayName(id),
|
||||
internalDomain: raw.internal_domain || tenantHomeFqdn(id, internalBase),
|
||||
service: raw.service || tenantServiceName(id),
|
||||
sites: (raw.sites || []).map((site) => ({
|
||||
id: normalizeResourceId(site.id),
|
||||
exposure: site.exposure || 'disabled',
|
||||
fqdn:
|
||||
(site.exposure || 'disabled') === 'public' && !publicBase
|
||||
? null
|
||||
: siteFqdn(
|
||||
site.id,
|
||||
id,
|
||||
site.exposure || 'disabled',
|
||||
internalBase,
|
||||
publicBase,
|
||||
),
|
||||
})),
|
||||
sites,
|
||||
databases: {
|
||||
brain: raw.databases?.brain || tenantBrainDbName(id),
|
||||
ops: raw.databases?.ops || tenantOpsDbName(id),
|
||||
|
|
@ -606,17 +604,17 @@ function validateTenantRecord(
|
|||
if (registry.platform.reservedHostLabels.includes(siteIdAlias)) {
|
||||
throw new Error(`Tenant site id conflicts with reserved host label: ${site.id}`);
|
||||
}
|
||||
if (site.exposure === 'public' && !registry.platform.publicBase) {
|
||||
throw new Error(
|
||||
`Tenant site requires platform.public_base for public exposure: ${site.id}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
site.exposure === 'public' &&
|
||||
registry.platform.publishingMode !== 'public'
|
||||
) {
|
||||
throw new Error(
|
||||
`Tenant site cannot use public exposure when platform publishing mode is ${registry.platform.publishingMode}: ${site.id}`,
|
||||
`Tenant site cannot use public exposure when platform.publishing_mode is ${registry.platform.publishingMode}: ${site.id}`,
|
||||
);
|
||||
}
|
||||
if (site.exposure === 'public' && !registry.platform.publicBase) {
|
||||
throw new Error(
|
||||
`Tenant site requires platform.public_base; public_base is unset: ${site.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue